UNPKG

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

Version:

Fluid Design System Angular

435 lines (373 loc) 10.9 kB
import {CommonModule, DOCUMENT} from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ContentChildren, ElementRef, forwardRef, Inject, Input, OnDestroy, QueryList, ViewChild, ViewEncapsulation, } from '@angular/core'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import {finalize, race, Subject, takeUntil} from 'rxjs'; import {selectAnimations} from '../../shared/animations'; import {CustomLabelDirective} from '../custom-label/custom-label.directive'; 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 {SelectCustomLabelContext} from './select-custom-label-context.model'; @Component({ selector: 'nj-select', templateUrl: './select.component.html', styleUrls: ['./select.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => SelectComponent), multi: true, }, ], animations: [selectAnimations.transformList], encapsulation: ViewEncapsulation.None, standalone: true, imports: [ListGroupComponent, FormItemComponent, FormFieldDirective, CommonModule] }) export class SelectComponent extends FormItemComponent implements 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 */ selectedValue = ''; /** * @ignore */ selectedIndex = -1; @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 append after the input label. * @example "Select a value" */ @Input() buttonDefaultValueLabel: string; /** * Trigger button to toggle the list * @ignore */ @ViewChild('button') buttonEl: ElementRef<HTMLButtonElement>; @ViewChild('customLabelEl') protected customLabelEl: ElementRef<HTMLElement>; /** * List containing options * @ignore */ @ViewChild(ListGroupComponent) listEl: ListGroupComponent; /** * Label to display instead of raw text value * @ignore * @example * <ng-template njCustomLabel let-value let-index="index"> * Value: {{value}} - Index: {{index}} * </ng-template> * * @example * <span *njCustomLabel="let value;let index=index"> * Value: {{value}} - Index: {{index}} * </span> */ @ContentChild(CustomLabelDirective) protected customLabel?: CustomLabelDirective<SelectCustomLabelContext>; /** * 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(); } ngAfterViewInit() { setTimeout(() => { this.setInputsAndListenersOnOptions(); this.selectOptions?.changes .pipe(takeUntil(this.unsubscribe)) .subscribe(() => { setTimeout(() => { this.setInputsAndListenersOnOptions(); }); }); }); } ngOnDestroy() { this.unsubscribe.next(); this.unsubscribe.complete(); this.childOptionsChange.complete(); } setInputsAndListenersOnOptions() { this.childOptionsChange.next(); const unsubscribeCond$ = race(this.unsubscribe, this.childOptionsChange); this.selectOptions?.forEach((item) => { item.role = 'option'; if (this.selectedValue?.trim() !== '') { item.updateSelected(this.selectedValue === item.getValue()); } item.itemClick .pipe( takeUntil(unsubscribeCond$), ) .subscribe(() => { const value = item.getValue(); this.writeValue(value); this._onChange(value); this.closeList(); setTimeout(() => { this.buttonEl?.nativeElement.focus(); }); }); }); // Get selected index on mount based on current value this.selectedIndex = this.selectOptions?.toArray().findIndex(opt => { return opt.getValue() === this.selectedValue; }); this.cdr.markForCheck(); } /** * @ignore */ getAdditionalClass(): string { const classes = ['nj-form-item--select', 'nj-form-item--custom-list']; if (this.isOpen) { classes.push('nj-form-item--open'); } if (this.customLabel?.templateRef) { classes.push('nj-form-item--custom-label'); } return classes.join(' '); } getSubscriptId(): string { return `${this.inputId}-subscript`; } getInstructionsId(): string { return `${this.inputId}-instructions`; } getDescriptionId(): string { return `${this.getSubscriptId()} ${this.getInstructionsId()}`; } /** * Get index of the selected value */ private indexForValue(value: string): number { return this.selectOptions ?.toArray() .findIndex((item) => item.getValue() === value); } private openList() { this.isOpen = true; this.focusedIndex = this.selectedIndex; setTimeout(() => { if (this.selectedIndex === -1) { // 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; } toggleIsOpen() { if (this.isOpen) { this.closeList(); } else { this.openList(); } } /** * 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 !== -1) { this.selectOptions?.get(value).el.nativeElement.focus(); } }); } handleListKeydown(e: KeyboardEvent) { // Escape key closes the list and focuses the button if (e.code === SelectComponent.ESCAPE_CODE) { this.closeList(); setTimeout(() => { this.buttonEl?.nativeElement.focus(); }); } // Navigate between options and set `focusedIndex` if (e.code === SelectComponent.UP_CODE) { e.preventDefault(); // Dont loop back to the end of the list if (this.focusedIndex > 0) { this.focusedIndex -= 1; } } if (e.code === SelectComponent.DOWN_CODE) { e.preventDefault(); // Dont loop back to the beginning of the list if (this.focusedIndex < this.selectOptions?.length - 1) { this.focusedIndex += 1; } } // Select the current `focusedIndex` option if (e.code === SelectComponent.ENTER_CODE) { e.preventDefault(); if (this.focusedIndex !== -1) { const value = this.selectOptions?.get(this.focusedIndex).getValue(); this.writeValue(value); this._onChange(value); } this.closeList(); setTimeout(() => { this.buttonEl?.nativeElement.focus(); }); } // Jump to first option matching first letter if (SelectComponent.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; } } } handleFocusout(e: FocusEvent) { if (!this.element.nativeElement?.contains(e.relatedTarget 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 { if (!this.selectedValue) { return; } this.isDisabled = isDisabled; } /** * Implemented as part of ControlValueAccessor. * @ignore */ writeValue(value: string): void { this.selectedValue = value; this.selectedIndex = this.indexForValue(value); this.selectOptions?.forEach((item) => { item.updateSelected(item.getValue() === value); }); this.cdr.markForCheck(); } protected get customLabelContext(): SelectCustomLabelContext { const value = this.selectedValue; const index = this.selectedIndex; return {$implicit: value, value, index}; } /** * Label (≠ value) of selected option * @ignore */ get selectedLabel(): string { return this.selectOptions?.get(this.selectedIndex)?.getLabel() ?? ''; } /** * Aria-label for the trigger button element. * @ignore */ get buttonLabel(): string { return `${this.fieldLabel} - ${ this.customLabelEl?.nativeElement.innerText || this.selectedValue || this.buttonDefaultValueLabel }`; } }