@engie-group/fluid-design-system-angular
Version:
Fluid Design System Angular
620 lines (541 loc) • 15.5 kB
text/typescript
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';
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
*/
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 appended after the input label.
* @example "Select a value"
*/
buttonDefaultValueLabel: string;
/**
* Max tags to display
*/
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`
*/
displaySelectedItems = true;
/**
* Selected text when `displaySelectedItems = false`:
* @example '5 selected`
*/
selectedText: string = 'selected';
/**
* Selected options tag color. Can only be grey or brand
*/
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
*/
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
*/
tagResetSelectionLabel?: string = 'Deselect all';
/**
* Trigger button to toggle the list
* @ignore
*/
buttonEl: ElementRef<HTMLButtonElement>;
/**
* Trigger button to toggle the list
* @ignore
*/
input: ElementRef<HTMLInputElement>;
/**
* List containing options
* @ignore
*/
listEl: ListGroupComponent;
/**
* List containing tags
* @ignore
*/
tags: QueryList<TagComponent>;
/**
* Option items
* @ignore
*/
selectOptions: QueryList<ListItemComponent>;
constructor(private readonly element: ElementRef<HTMLElement>, private readonly cdr: ChangeDetectorRef, 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}`;
}
}