UNPKG

@engie-group/fluid-design-system-angular

Version:

Fluid Design System Angular

620 lines (541 loc) 15.5 kB
import {CommonModule, DOCUMENT} from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChildren, ElementRef, forwardRef, Inject, Input, OnDestroy, OnInit, QueryList, ViewChild, ViewChildren, ViewEncapsulation, } from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import {fromEvent, race, Subject, takeUntil} from 'rxjs'; import {selectAnimations} from '../../shared/animations'; import {FormFieldDirective} from '../form-field/form-field.directive'; import {FormItemComponent} from '../form-item/form-item.component'; import {ListGroupComponent} from '../list-group/list-group.component'; import {ListItemComponent} from '../list-item/list-item.component'; import {TagComponent} from '../tag/tag.component'; import {TagSize} from '../tag/tag.model'; @Component({ selector: 'nj-multi-select', templateUrl: './multi-select.component.html', styleUrls: ['./multi-select.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => MultiSelectComponent), multi: true, }, ], animations: [selectAnimations.transformList], encapsulation: ViewEncapsulation.None, standalone: true, imports: [TagComponent, FormItemComponent, FormFieldDirective, ListGroupComponent, CommonModule] }) export class MultiSelectComponent extends FormItemComponent implements OnInit, AfterViewInit, ControlValueAccessor, OnDestroy { private static readonly ESCAPE_CODE = 'Escape'; private static readonly ENTER_CODE = 'Enter'; private static readonly UP_CODE = 'ArrowUp'; private static readonly DOWN_CODE = 'ArrowDown'; /* Regex matching every alpha-numeric characters. \d : every digits \p{Letter} : every letters in the latin alphabet including letters with diacritics The "u" flag enables unicode mode required to use `\p{Letter}`. See : - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Unicode_Property_Escapes#general_categories - https://unicode.org/reports/tr18/#General_Category_Property */ private static readonly ALPHA_NUMERIC_REGEX = /^[\d\p{Letter}]$/u; /** * @ignore */ private _onChange = (_: any): void => { }; /** * @ignore */ private _onTouched = (): void => { }; /** * Notifier used to stop items click event subscription. * @ignore */ private unsubscribe = new Subject<void>(); private childOptionsChange = new Subject<void>(); /** * @ignore */ isOpen = false; /** * @ignore */ selectedIndexes: Set<number> = new Set<number>(); /** * @ignore */ selectedValues: Set<string> = new Set<string>(); /** * Icon name */ @Input() iconName = 'keyboard_arrow_down'; /** * Label used for accessibility related attributes on button and list. * Should be the same value (text only) as the `<label>` element */ @Input() fieldLabel: string; /** * Instructions on how to navigate the list. It is append after the input label. * @example "Use up and down arrows and Enter to select a value" */ @Input() listNavigationLabel: string; /** * Button default label when no value is selected. It is appended after the input label. * @example "Select a value" */ @Input() buttonDefaultValueLabel: string; /** * Max tags to display */ @Input() maxTagsToDisplay: number; /** * Whether or no to display selected items. When set to `false` and number of selected > 1, * instead of displaying each selected item as tags it only display one tag `X selected` */ @Input() displaySelectedItems = true; /** * Selected text when `displaySelectedItems = false`: * @example '5 selected` */ @Input() selectedText: string = 'selected'; /** * Selected options tag color. Can only be grey or brand */ @Input() tagColor: 'brand' | 'grey' = 'grey'; /** * Tag close label for accessibility, default is `Deselect` and it will be followed by the tag label. So focusing the close icon will read `Remove tag label`. * Make sure to set a meaningful value and a translated one */ @Input() tagCloseLabel = 'Deselect'; /** * Tag close label for accessibility, default is `Deselect all`. So focusing the close icon will read `Deselect all`. * Make sure to set a meaningful value and a translated one */ @Input() tagResetSelectionLabel?: string = 'Deselect all'; /** * Trigger button to toggle the list * @ignore */ @ViewChild('button') buttonEl: ElementRef<HTMLButtonElement>; /** * Trigger button to toggle the list * @ignore */ @ViewChild('input') input: ElementRef<HTMLInputElement>; /** * List containing options * @ignore */ @ViewChild(ListGroupComponent) listEl: ListGroupComponent; /** * List containing tags * @ignore */ @ViewChildren('tags') tags: QueryList<TagComponent>; /** * Option items * @ignore */ @ContentChildren(ListItemComponent, {descendants: true}) selectOptions: QueryList<ListItemComponent>; constructor(private readonly element: ElementRef<HTMLElement>, private readonly cdr: ChangeDetectorRef, @Inject(DOCUMENT) private document) { super(); } ngOnInit() { fromEvent(this.document, 'click') .pipe(takeUntil(this.unsubscribe)) .subscribe((e: MouseEvent) => this.handleOutsideClick(e)); } ngAfterViewInit() { this.setInputsAndListenersOnOptions(); this.updateSelectedIndexes(); this.cdr.detectChanges(); this.selectOptions?.changes .pipe(takeUntil(this.unsubscribe)) .subscribe(() => { setTimeout(() => { this.setInputsAndListenersOnOptions(); this.updateSelectedIndexes(); this.cdr.detectChanges(); }); }); } ngOnDestroy() { this.unsubscribe.next(); this.unsubscribe.complete(); } setInputsAndListenersOnOptions() { this.childOptionsChange.next(); const unsubscribeCond$ = race(this.unsubscribe, this.childOptionsChange); this.selectOptions?.forEach((item, index) => { item.role = 'option'; item.updateSelected(this.selectedIndexes.has(index)); item.isCheckboxContent = true; item.checkboxContentId = this.getOptionId(index); item.itemClick .pipe(takeUntil(unsubscribeCond$)) .subscribe(() => { this.toggleItem(item, index); }); }); } updateSelectedIndexes() { const selectOptionsArr = this.selectOptions?.toArray(); if (selectOptionsArr) { this.selectedValues = new Set( [...this.selectedValues].filter(value => selectOptionsArr.some(opt => { return opt?.getValue() === value; })) ); this.selectedIndexes = new Set( selectOptionsArr .map((opt, index) => { return this.selectedValues.has(opt.getValue()) ? index : -1; }) .filter(index => index >= 0) ); } else { this.selectedIndexes.clear(); this.selectedValues.clear(); } this.updateOptionsActive(); } /** * @ignore */ toggleByIndex(e: MouseEvent, index: number, tagIndex: number) { e?.stopPropagation(); const item = this.selectOptions.get(index); this.toggleItem(item, index); // When clicking with a mouse e.detail counts the number of clicks, however when using keyboard it is always 0 const isEventTriggeredWithKeyboard = e?.detail === 0; if (!isEventTriggeredWithKeyboard) { this.buttonEl?.nativeElement?.focus(); return; } // We use a set timeout to make sure the focus is done after is re-rendered setTimeout(() => { if (this.tags.length) { const indexToFocus = tagIndex === this.tags.length ? tagIndex - 1 : tagIndex; this.tags.get(indexToFocus)?.focusIconButton(); } else { this.buttonEl?.nativeElement?.focus(); } }); } /** * @ignore */ resetSelection(e: Event) { e.stopPropagation(); this.buttonEl.nativeElement.focus(); this.selectedIndexes.clear(); this.selectedValues.clear(); this.updateOptionsActive(); this._onChange(Array.from(this.selectedValues)); } private toggleItem(item: ListItemComponent, index: number) { if (!item) { return; } this.toggleValueInSelectedValue(item?.getValue()); this.toggleIndexInSelected(index); this.updateOptionsActive(); this.cdr.markForCheck(); this._onChange(Array.from(this.selectedValues)); } private toggleValueInSelectedValue(value: string) { if (this.selectedValues.has(value)) { this.selectedValues.delete(value); } else { this.selectedValues.add(value); } } private toggleIndexInSelected(index: number) { if (this.selectedIndexes.has(index)) { this.selectedIndexes.delete(index); } else { this.selectedIndexes.add(index); } } private openList() { this.isOpen = true; this.focusedIndex = this.selectedIndexes.size ? Array.from(this.selectedIndexes)[this.selectedIndexes.size - 1] : 0; this.selectOptions?.toArray().forEach((el, i) => { if (!el) { return; } el.isActive = this.selectedIndexes.has(i); }); setTimeout(() => { if (!this.selectedIndexes?.size) { // Focus the `ul` element this.listEl?.rootEl.nativeElement.focus(); // The scrolling element is not the `ul` node but the `nj-list-group` this.listEl?.element.nativeElement.scrollTo({top: 0}); } }); } private closeList() { this.isOpen = false; this.cdr.markForCheck(); } /** * @ignore */ toggleIsOpen() { if (this.isOpen) { this.closeList(); } else { this.openList(); } } /** * @ignore */ handleListKeydown(e: KeyboardEvent) { // Escape key closes the list and focuses the button if (e.code === MultiSelectComponent.ESCAPE_CODE) { this.closeList(); setTimeout(() => { this.buttonEl?.nativeElement.focus(); }); } // Navigate between options and set `focusedIndex` if (e.code === MultiSelectComponent.UP_CODE) { e.preventDefault(); // Don't loop back to the end of the list if (this.focusedIndex > 0) { this.focusedIndex -= 1; } } if (e.code === MultiSelectComponent.DOWN_CODE) { e.preventDefault(); // Don't loop back to the begining of the list if (this.focusedIndex < this.selectOptions?.length - 1) { this.focusedIndex += 1; } } // Select the current `focusedIndex` option if (e.code === MultiSelectComponent.ENTER_CODE) { e.preventDefault(); if (this.focusedIndex !== -1) { this.toggleItem(this.selectOptions?.get(this.focusedIndex), this.focusedIndex); this._onChange(Array.from(this.selectedValues)); } } // Jump to first option matching first letter if (MultiSelectComponent.ALPHA_NUMERIC_REGEX.test(e.key)) { const goToIndex = this.selectOptions ?.toArray() .findIndex( (item) => item.getValue()[0].toLowerCase() === e.key.toLowerCase() ); if (goToIndex !== -1) { this.focusedIndex = goToIndex; } } } /** * @ignore */ handleFocusOut(e: FocusEvent) { const relatedTarget = e?.relatedTarget as Node; if (!relatedTarget) { return; } if (!this.element?.nativeElement?.contains(relatedTarget)) { this.closeList(); if (this._onTouched) { this._onTouched(); } } } /** * @ignore */ handleOutsideClick(e: MouseEvent) { if (!this.element?.nativeElement?.contains(e.target as Node)) { this.closeList(); if (this._onTouched) { this._onTouched(); } } } /** * Implemented as part of ControlValueAccessor. * @ignore */ registerOnChange(fn: any): void { this._onChange = fn; } /** * Implemented as part of ControlValueAccessor. * @ignore */ registerOnTouched(fn: any): void { this._onTouched = fn; } /** * Implemented as part of ControlValueAccessor. * @ignore */ setDisabledState(isDisabled: boolean): void { this.isDisabled = isDisabled; } /** * Implemented as part of ControlValueAccessor. * @ignore */ writeValue(values?: string[]): void { this.selectedValues.clear(); this.selectedIndexes.clear(); if (values?.length) { for (const value of values) { this.selectedValues.add(value); const indexToAdd = this.selectOptions?.toArray()?.findIndex(item => item.getValue() === value); if (indexToAdd >= 0) { this.selectedIndexes.add(indexToAdd); } } } this.updateOptionsActive(); this.cdr.markForCheck(); } private updateOptionsActive() { this.selectOptions?.forEach((item) => { item.updateSelected(this.selectedValues.has(item.getValue())); }); } /** * Label (≠ value) of selected option * @ignore */ getLabelAtIndex(index: number): string { return this.selectOptions?.get(index)?.getLabel() ?? ''; } /** * Close aria Label of taf to close * @param index */ getTagCloseLabel(index: number): string { return `${this.tagCloseLabel} ${this.getLabelAtIndex(index)}`; } /** * Aria-label for the trigger button element. * @ignore */ get buttonLabel(): string { return `${this.fieldLabel} - ${this.buttonDefaultValueLabel}`; } /** * @ignore */ get formattedInputValue(): string { if (!this.selectedValues) { return ''; } return Array.from(this.selectedValues).join(','); } /** * @ignore */ getAdditionalClass(): string { return `nj-form-item--select nj-form-item--custom-list nj-form-item--multi-select${ this.isOpen ? ' nj-form-item--open' : '' }`; } /** * @ignore */ getSubscriptId(): string { return `${this.inputId}-subscript`; } /** * @ignore */ getInstructionsId(): string { return `${this.inputId}-instructions`; } /** * @ignore */ getDescriptionId(): string { return `${this.getSubscriptId()} ${this.getInstructionsId()}`; } /** * Index of the currently focused option. */ private get focusedIndex(): number { return this.selectOptions ?.toArray() .findIndex( (item) => this.document.activeElement === item.el.nativeElement ); } private set focusedIndex(value: number) { this.selectOptions?.forEach((el, i) => { el.ariaSelected = i === value; }); setTimeout(() => { if (value >= 0) { this.selectOptions?.get(value)?.el?.nativeElement?.focus(); } }); } /** * @ignore */ get selectedIndexesToShow(): number[] { if (!this.selectedIndexes) { return; } if (!this.maxTagsToDisplay) { return [...this.selectedIndexes]; } return [...this.selectedIndexes].splice(0, this.maxTagsToDisplay); } /** * @ignore */ get tagSize(): TagSize { switch (this.size) { case 'xlarge': return 'md'; case 'small': return 'xs'; default: return 'sm'; } } /** * @ignore */ get selectIndexAsArray(): number[] { return [...this.selectedIndexes]; } private getOptionId(index: number): string { return `${this.inputId}_option-${index}`; } }