@engie-group/fluid-design-system-angular
Version:
Fluid Design System Angular
435 lines (373 loc) • 10.9 kB
text/typescript
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';
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;
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
*/
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"
*/
listNavigationLabel: string;
/**
* Button default label when no value is selected. It is append after the input label.
* @example "Select a value"
*/
buttonDefaultValueLabel: string;
/**
* Trigger button to toggle the list
* @ignore
*/
buttonEl: ElementRef<HTMLButtonElement>;
protected customLabelEl: ElementRef<HTMLElement>;
/**
* List containing options
* @ignore
*/
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>
*/
protected customLabel?: CustomLabelDirective<SelectCustomLabelContext>;
/**
* Option items
* @ignore
*/
selectOptions: QueryList<ListItemComponent>;
constructor(
private readonly element: ElementRef<HTMLElement>,
private readonly cdr: ChangeDetectorRef,
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
}`;
}
}