feature(Teknisk skuld): Refaktorisera overlay och multiselect (används i filtren i Avrop). Förberedande arbete inför att göra egna komponenter för dialog och popover (TV-845)

Merge in TEA/mina-sidor-fa-web from feature/TV-845-move-overlays-(dialog,-popover)-to-@ui to develop

Squashed commit of the following:

commit b98faf62aa47a155acb5609cdbdbc31a2c726ee9
Author: Daniel Appelgren <daniel.appelgren@arbetsformedlingen.se>
Date:   Tue Nov 30 09:13:48 2021 +0100

    Update overlay-trigger-for.directive.ts

commit 3945b98f702e82e100cb223da8da0be02fd48d17
Author: Daniel Appelgren <daniel.appelgren@arbetsformedlingen.se>
Date:   Mon Nov 29 11:45:33 2021 +0100

    Update avrop.service.ts

commit 4c65724cac9825a599feb003c725ac37a40d03fa
Author: Daniel Appelgren <daniel.appelgren@arbetsformedlingen.se>
Date:   Mon Nov 29 11:40:08 2021 +0100

    cleanup

commit aad6c2fc64e7d8c4d19da5f3c061f92a953be59e
Author: Daniel Appelgren <daniel.appelgren@arbetsformedlingen.se>
Date:   Mon Nov 29 11:06:21 2021 +0100

    refactor overlay and multiselect

commit cf57126f36416f6e9de60d0048e2494bc4141188
Merge: 94257008 609698eb
Author: Daniel Appelgren <daniel.appelgren@arbetsformedlingen.se>
Date:   Mon Nov 29 08:41:55 2021 +0100

    Merge branch 'develop' into feature/TV-845-move-overlays-(dialog,-popover)-to-@ui

commit 942570081f4bad6e6dc68f5416619cd64d9d6bb4
Author: Daniel Appelgren <daniel.appelgren@arbetsformedlingen.se>
Date:   Wed Nov 24 15:44:56 2021 +0100

    migrate Dropdown to UI and extract Overlay part for other usage
This commit is contained in:
Daniel Appelgren
2021-11-30 09:14:59 +01:00
parent 10f279b5fd
commit 8aef512882
23 changed files with 116 additions and 96 deletions

View File

@@ -0,0 +1,6 @@
export interface MultiselectFilterOption {
label?: string;
id?: string;
count?: number;
secondary?: boolean;
}

View File

@@ -0,0 +1,60 @@
<form autocomplete="off">
<div class="multiselect-panel">
<div class="multiselect-panel__content">
<fieldset
class="multiselect-panel__fieldset"
*ngIf="availableOptions?.length > 0 else noOptionsWithCurrentFilters"
>
<legend class="multiselect-panel__legend">{{ multiselectTitle }}</legend>
<p *ngIf="description" class="multiselect-panel__description">{{ description }}</p>
<div class="multiselect-panel__checkboxes">
<ul class="multiselect-panel__checkboxes-list">
<li
class="multiselect-panel__checkboxes-item"
*ngFor="let multiselectOption of availableOptions; let i = index"
>
<digi-ng-form-checkbox
[afLabel]="multiselectOption.label + ' (' + (multiselectOption.count || 0) + ')'"
(change)="setOptionState(multiselectOption, $event.target.checked)"
[ngModel]="isSelected(multiselectOption)"
[name]="multiselectOption.id"
></digi-ng-form-checkbox>
</li>
<ng-container *ngIf="selectedUnavailableOptions.length">
<li
class="multiselect-panel__checkboxes-item"
[ngClass]="{'multiselect-panel__checkboxes-item--unavailable': i === 0}"
*ngFor="let multiselectOption of selectedUnavailableOptions; let i = index"
>
<digi-ng-form-checkbox
[afLabel]="multiselectOption.label + ' (0)'"
(change)="setOptionState(multiselectOption, $event.target.checked)"
[ngModel]="isSelected(multiselectOption)"
[name]="multiselectOption.id"
[afSecondary]="true"
></digi-ng-form-checkbox>
</li>
</ng-container>
</ul>
</div>
</fieldset>
</div>
<div class="multiselect-panel__footer">
<digi-button (click)="emitSelectedOptions()" af-size="m">
<span>{{confirmButtonText}}</span>
<span class="msfa__a11y-sr-only">&nbsp;och stäng dialogen för filter {{ multiselectTitle }}</span>
</digi-button>
</div>
</div>
</form>
<ng-template #noOptionsWithCurrentFilters>
<div class="multiselect-panel__no-options-available">
<p>
I kombination med dina nuvarande filterval, finns det inga nya deltagare med {{ multiselectTitlePlural }} att
filtrera på.
</p>
</div>
</ng-template>

View File

@@ -0,0 +1,111 @@
@import 'libs/styles/src/mixins/form';
@import 'libs/styles/src/mixins/a11y';
.multiselect-panel {
&__checkboxes {
max-width: var(--digi--typography--text--max-width);
padding: var(--digi--layout--padding--15);
}
&__fieldset {
@include msfa__fieldset;
}
&__checkboxes-list {
list-style: none;
margin: 0;
padding: 0;
}
&__checkboxes-item {
margin-top: var(--digi--layout--gutter--s);
&:first-child {
margin-top: 0;
}
&--unavailable {
margin-top: var(--digi--layout--gutter);
}
}
&__search-wrapper {
position: relative;
margin-bottom: var(--digi--layout--gutter--l);
&--searched {
margin-bottom: var(--digi--layout--gutter--s);
}
}
&__select-all {
margin-bottom: var(--digi--layout--gutter--l);
}
&__input-reset-button {
position: absolute;
right: 0;
top: 1px;
}
}
.multiselect-panel {
position: relative;
display: flex;
flex-direction: column;
::ng-deep &__backdrop {
background-color: rgba(var(--digi--ui--color--background--dark), 40%);
}
&__fieldset {
margin: 0;
padding: 0;
border-width: 0;
}
&__legend {
position: relative;
text-align: left;
}
&__heading,
&__legend {
width: 100%;
height: 45px;
display: flex;
align-items: center;
margin: 0;
font-weight: var(--digi--typography--font-weight--bold);
font-size: var(--digi--typography--font-size);
padding: var(--digi--layout--gutter--s) var(--digi--layout--gutter);
border-bottom: 1px solid var(--digi--ui--color--background--tertiary);
}
&__content {
position: relative;
flex-grow: 1;
}
&__inner-content {
width: 100%;
min-width: 300px;
max-width: 800px;
overflow-y: auto;
overflow-x: hidden;
}
&__footer {
display: flex;
justify-content: center;
align-items: center;
background-color: var(--digi--ui--color--background--tertiary);
padding: $digi--layout--gutter $digi--layout--gutter--l;
}
&__no-options-available {
text-align: center;
padding: var(--digi--layout--gutter--s);
max-width: 300px;
}
}

View File

@@ -0,0 +1,25 @@
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MultiselectPanelComponent } from './multiselect-panel.component';
describe('MultiselectPanelComponent', () => {
let component: MultiselectPanelComponent;
let fixture: ComponentFixture<MultiselectPanelComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [MultiselectPanelComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(MultiselectPanelComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,71 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
Output,
SimpleChanges,
} from '@angular/core';
import { MultiselectFilterOption } from '@ui/multiselect/multiselect-filter-option';
import { BehaviorSubject } from 'rxjs';
import { OverlayTriggerForDirective } from '@ui/overlay/overlay-trigger-for.directive';
@Component({
selector: 'ui-multiselect-panel',
templateUrl: './multiselect-panel.component.html',
styleUrls: ['./multiselect-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MultiselectPanelComponent implements OnChanges {
@Input() overlayRef: OverlayTriggerForDirective;
@Input() confirmButtonText = 'Spara';
@Input() multiselectTitle: string;
@Input() multiselectTitlePlural: string;
@Input() description: string;
@Input() availableOptions: MultiselectFilterOption[];
@Input() selectedOptions: MultiselectFilterOption[];
@Output() selectedOptionsChange = new EventEmitter<MultiselectFilterOption[]>();
private _pendingSelectedOptions$ = new BehaviorSubject<MultiselectFilterOption[]>([]);
private _selectedUnavailableOptions$ = new BehaviorSubject<MultiselectFilterOption[]>([]);
ngOnChanges(changes: SimpleChanges): void {
if (changes.availableOptions || changes.selectedOptions) {
this._setPendingSelectedOptions();
}
}
get selectedUnavailableOptions(): MultiselectFilterOption[] {
return this._selectedUnavailableOptions$.getValue();
}
private get _pendingSelectedOptions(): MultiselectFilterOption[] {
return this._pendingSelectedOptions$.getValue();
}
private _setPendingSelectedOptions(): void {
this._pendingSelectedOptions$.next(this.selectedOptions || []);
this._selectedUnavailableOptions$.next(
this.selectedOptions?.filter(
selectedOption => !this.availableOptions.some(availableOption => availableOption.id === selectedOption.id)
) || []
);
}
isSelected(filterOption: MultiselectFilterOption): boolean {
return this._pendingSelectedOptions$.value?.some(selectedOption => selectedOption.id === filterOption.id) ?? false;
}
emitSelectedOptions(): void {
this.selectedOptionsChange.emit(this._pendingSelectedOptions);
}
setOptionState(filterOption: MultiselectFilterOption, isSelected: boolean): void {
if (isSelected) {
this._pendingSelectedOptions$.next([...this._pendingSelectedOptions, filterOption]);
} else {
this._pendingSelectedOptions$.next(
this._pendingSelectedOptions.filter(item => item.id !== filterOption.id) ?? []
);
}
}
}

View File

@@ -0,0 +1,38 @@
<button
#multiselectButton
[uiOverlayTriggerFor]="overlay"
[uiOverlayPositions]="overlayPositions"
[attr.id]="buttonElementId"
[attr.aria-controls]="panelId"
[ngClass]="{'multiselect__toggle-btn--invalid': isInvalid && showValidation}"
type="button"
class="multiselect__toggle-btn"
>
<span class="multiselect__toggle-btn__text">{{multiselectTitle}}</span>
<digi-icon-arrow-down aria-hidden="true" class="multiselect__toggle-btn__arrow-down"></digi-icon-arrow-down>
<span class="msfa__a11y-sr-only"> Filtrera på {{multiselectTitle}}. </span>
</button>
<ui-overlay #overlay>
<div class="multiselect__overlay-content" [attr.id]="panelId" cdkTrapFocus cdkTrapFocusAutoCapture>
<ui-multiselect-panel
[availableOptions]="availableOptions"
[selectedOptions]="selectedOptions"
[overlayRef]="overlay"
[multiselectTitle]="multiselectTitle"
[confirmButtonText]="confirmButtonText"
(selectedOptionsChange)="emitSelectedOptions($event)"
></ui-multiselect-panel>
</div>
</ui-overlay>
<div class="msfa__a11y-sr-only" aria-live="polite">
<span *ngIf="selectedOptions"
>Antal valda {{multiselectTitle.toLocaleLowerCase()}} är {{selectedOptions.length}}</span
>
</div>
<div class="msfa__a11y-sr-only" aria-live="polite">
<ul *ngFor="let option of selectedOptions">
<li>{{option.label}}</li>
</ul>
</div>

View File

@@ -0,0 +1,67 @@
@import 'libs/styles/src/variables/colors';
@import 'libs/styles/src/mixins/list';
@import 'libs/styles/src/variables/gutters';
@import 'libs/styles/src/variables/shadows';
.multiselect {
&__overlay__content {
background-color: white;
border-radius: var(--digi--ui--border--radius);
box-shadow: $msfa__shadow;
}
&__toggle-btn {
margin-right: var(--digi--layout--gutter);
display: flex;
justify-content: space-between;
align-items: center;
appearance: none;
width: 100%;
height: var(--digi--ui--input--height);
padding: var(--digi--ui--input--padding);
border: 1px solid var(--digi--ui--input--border--color);
cursor: pointer;
font-size: var(--digi--typography--font-size--m);
color: var(--digi--ui--color--background--overlay--opaque);
background-color: var(--digi--ui--color--background);
&:focus {
border-color: var(--digi--ui--color--focus--light);
box-shadow: 0 0 0.1rem 0.05rem var(--digi--ui--color--focus);
outline: none;
}
&:disabled {
cursor: default;
background-color: var(--digi--ui--color--background--disabled);
border-color: var(--digi--ui--color--border--disabled);
color: var(--digi--typography--color--text--disabled);
}
&--invalid {
background-color: var(--digi--ui--color--background--error);
border-color: var(--digi--ui--color--border--error);
box-shadow: 0 0 0 1px var(--digi--ui--color--border--error);
}
&__text {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
&__toggle-btn__arrow-down {
padding: var(--digi--layout--padding);
}
&__validation-messages {
@include msfa__reset-list;
display: flex;
justify-content: space-between;
padding: var(--digi--layout--gutter--s) 0;
flex-direction: column;
gap: var(--digi--layout--gutter--s);
}
}

View File

@@ -0,0 +1,27 @@
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MultiselectComponent } from './multiselect.component';
import { DropdownModule } from '@ui/dropdown/dropdown.module';
describe('MultiselectComponent', () => {
let component: MultiselectComponent;
let fixture: ComponentFixture<MultiselectComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DropdownModule],
declarations: [MultiselectComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(MultiselectComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,115 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
Component,
ElementRef,
EventEmitter,
forwardRef,
Input,
Output,
Renderer2,
ViewChild,
} from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { MultiselectFilterOption } from '@ui/multiselect/multiselect-filter-option';
import { uuid } from '@utils/uuid.util';
import { OverlayTriggerForDirective } from '@ui/overlay/overlay-trigger-for.directive';
import { ConnectedPosition } from '@angular/cdk/overlay';
interface PropagateChangeFn {
(_: unknown): void;
}
interface PropagateTouchedFn {
(): void;
}
@Component({
selector: 'ui-multiselect',
templateUrl: './multiselect.component.html',
styleUrls: ['./multiselect.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => MultiselectComponent),
multi: true,
},
],
})
export class MultiselectComponent implements AfterViewInit, ControlValueAccessor {
@ViewChild(OverlayTriggerForDirective) dropdownRef: OverlayTriggerForDirective;
panelId = `panel-${uuid()}`;
@Input() buttonElementId: string = uuid();
@Input() isInvalid = false;
@Input() showValidation = false;
@Input() confirmButtonText = 'Spara';
@Input() description: string;
@Input() multiselectTitle: string;
@Input() availableOptions: MultiselectFilterOption[];
@Input() selectedOptions: MultiselectFilterOption[];
@Output() selectedOptionsChange = new EventEmitter<MultiselectFilterOption[]>();
@ViewChild('multiselectButton') multiselectButton: ElementRef;
overlayPositions: ConnectedPosition[] = [
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 3,
},
];
isDisabled = false;
private propagateChange: PropagateChangeFn;
private propagateTouched: PropagateTouchedFn;
constructor(private renderer: Renderer2) {}
// Allows Angular to update the model.
// Update the model and changes needed for the view here.
writeValue(selectedOptions: MultiselectFilterOption[]): void {
this.selectedOptions = selectedOptions;
}
// Allows Angular to register a function to call when the model changes.
// Save the function as a property to call later here.
registerOnChange(fn: PropagateChangeFn): void {
this.propagateChange = fn;
}
// Allows Angular to register a function to call when the input has been touched.
// Save the function as a property to call later here.
registerOnTouched(fn: PropagateTouchedFn): void {
this.propagateTouched = fn;
}
// Allows Angular to disable the input.
setDisabledState?(isDisabled: boolean): void {
this.renderer.setProperty(this.multiselectButton.nativeElement, 'disabled', isDisabled);
this.dropdownRef.closeDropdown();
}
emitSelectedOptions(selectedOptions: MultiselectFilterOption[]): void {
// If used with FormsModule/ReactiveFormsModule
if (this.propagateChange && this.propagateTouched) {
this.propagateChange(selectedOptions);
this.propagateTouched();
} else {
this.selectedOptionsChange.emit(selectedOptions);
}
this.dropdownRef.closeDropdown();
}
ngAfterViewInit(): void {
if (!this.dropdownRef) {
return;
}
this.dropdownRef.onOpen(() => {
// force refresh of selected options if it's reopened
this.selectedOptions = [...(this.selectedOptions || [])];
});
}
}

View File

@@ -0,0 +1,16 @@
import { DigiNgFormCheckboxModule } from '@af/digi-ng/_form/form-checkbox';
import { A11yModule } from '@angular/cdk/a11y';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MultiselectComponent } from '@ui/multiselect/multiselect.component';
import { MultiselectPanelComponent } from './multiselect-panel/multiselect-panel.component';
import { UiOverlayModule } from '@ui/overlay/overlay.module';
@NgModule({
declarations: [MultiselectComponent, MultiselectPanelComponent],
imports: [CommonModule, A11yModule, DigiNgFormCheckboxModule, FormsModule, UiOverlayModule],
exports: [MultiselectComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class UiMultiselectModule {}

View File

@@ -0,0 +1,101 @@
import { ConnectedPosition, Overlay, OverlayRef } from '@angular/cdk/overlay';
import { TemplatePortal } from '@angular/cdk/portal';
import { Directive, ElementRef, HostListener, Input, OnDestroy, ViewContainerRef } from '@angular/core';
import { merge, Observable, Subscription } from 'rxjs';
import { OverlayPanel } from './overlay.model';
@Directive({
selector: '[uiOverlayTriggerFor]',
})
export class OverlayTriggerForDirective implements OnDestroy {
@Input() public uiOverlayTriggerFor: OverlayPanel;
@Input() public uiOverlayPositions: ConnectedPosition[] = [
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 3,
},
];
private isOverlayOpen = false;
public overlayRef: OverlayRef;
private overlayClosingActionsSub = Subscription.EMPTY;
private onClosedCallback: () => void = () => {
return;
};
private onOpenCallback: () => void = () => {
return;
};
@HostListener('click') onClick(): void {
return this.toggleDropdown();
}
@HostListener('document:keyup.escape', ['$event']) onKeydownHandler(): void {
this.closeDropdown();
}
constructor(
private overlay: Overlay,
private elementRef: ElementRef<HTMLElement>,
private viewContainerRef: ViewContainerRef
) {}
toggleDropdown(): void {
this.isOverlayOpen ? this.closeDropdown() : this.openOverlay();
}
openOverlay(): void {
this.isOverlayOpen = true;
this.overlayRef = this.overlay.create({
hasBackdrop: true,
backdropClass: 'cdk-overlay-transparent-backdrop',
scrollStrategy: this.overlay.scrollStrategies.close(),
positionStrategy: this.overlay
.position()
.flexibleConnectedTo(this.elementRef)
.withPositions(this.uiOverlayPositions),
});
const templatePortal = new TemplatePortal(this.uiOverlayTriggerFor.templateRef, this.viewContainerRef);
this.overlayRef.attach(templatePortal);
this.onOpenCallback();
this.overlayClosingActionsSub = this.dropdownClosingActions().subscribe(() => this.closeDropdown());
}
private dropdownClosingActions(): Observable<MouseEvent | void> {
const backdropClick$ = this.overlayRef.backdropClick();
const detachment$ = this.overlayRef.detachments();
const overlayClosed = this.uiOverlayTriggerFor.closed;
return merge(backdropClick$, detachment$, overlayClosed);
}
closeDropdown(): void {
if (!this.overlayRef || !this.isOverlayOpen) {
return;
}
this.onClosedCallback();
this.overlayClosingActionsSub.unsubscribe();
this.isOverlayOpen = false;
this.overlayRef.detach();
}
ngOnDestroy(): void {
if (this.overlayRef) {
this.overlayRef.dispose();
}
}
onClosed(fn: () => void): void {
this.onClosedCallback = fn;
}
onOpen(fn: () => void): void {
this.onOpenCallback = fn;
}
}

View File

@@ -0,0 +1,5 @@
<ng-template>
<div class="overlay__content" cdkTrapFocus cdkTrapFocusAutoCapture>
<ng-content></ng-content>
</div>
</ng-template>

View File

@@ -0,0 +1,7 @@
@import 'libs/styles/src/variables/shadows';
.overlay__content {
background-color: white;
border-radius: var(--digi--ui--border--radius);
box-shadow: $msfa__shadow;
}

View File

@@ -0,0 +1,24 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { OverlayComponent } from './overlay.component';
describe('DropdownComponent', () => {
let component: OverlayComponent;
let fixture: ComponentFixture<OverlayComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [OverlayComponent],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(OverlayComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Output, TemplateRef, ViewChild } from '@angular/core';
import { OverlayPanel } from './overlay.model';
@Component({
selector: 'ui-overlay',
templateUrl: './overlay.component.html',
styleUrls: ['./overlay.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class OverlayComponent implements OverlayPanel {
@ViewChild(TemplateRef) templateRef: TemplateRef<never>;
@Output() closed = new EventEmitter<void>();
}

View File

@@ -0,0 +1,6 @@
import { EventEmitter, TemplateRef } from '@angular/core';
export interface OverlayPanel {
templateRef: TemplateRef<never>;
readonly closed: EventEmitter<void>;
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { OverlayTriggerForDirective } from '../overlay/overlay-trigger-for.directive';
import { OverlayModule } from '@angular/cdk/overlay';
import { A11yModule } from '@angular/cdk/a11y';
import { OverlayComponent } from '@ui/overlay/overlay.component';
@NgModule({
declarations: [OverlayTriggerForDirective, OverlayComponent],
imports: [CommonModule, OverlayModule, A11yModule],
exports: [OverlayTriggerForDirective, OverlayComponent],
})
export class UiOverlayModule {}