UNPKG

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

Version:

Fluid Design System Angular

694 lines (606 loc) 15.8 kB
import { CommonModule, DOCUMENT } from '@angular/common'; import { AfterContentInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, ContentChild, ElementRef, EventEmitter, forwardRef, Inject, Input, OnDestroy, Output, QueryList, Renderer2, TemplateRef, ViewChild, ViewChildren, ViewEncapsulation } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { Subject } from 'rxjs'; import { selectAnimations } from '../../shared/animations'; import { Utils } from '../../utils/utils.util'; import { FormFieldDirective } from '../form-field/form-field.directive'; import { FormItemComponent } from '../form-item/form-item.component'; import { HighlightDirective } from '../highlight/highlight.directive'; import { ListGroupComponent } from '../list-group/list-group.component'; import { ListItemComponent } from '../list-item/list-item.component'; import { AutocompleteOption } from './autocomplete.model'; @Component({ selector: 'nj-autocomplete', templateUrl: './autocomplete.component.html', changeDetection: ChangeDetectionStrategy.OnPush, providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AutocompleteComponent), multi: true } ], animations: [selectAnimations.transformList], encapsulation: ViewEncapsulation.None, standalone: true, imports: [ ListGroupComponent, ListItemComponent, FormItemComponent, FormFieldDirective, HighlightDirective, CommonModule ] }) export class AutocompleteComponent extends FormItemComponent implements ControlValueAccessor, AfterContentInit, OnDestroy { private readonly INPUT_BORDER_IN_PX = 3; private readonly LIST_OFFSET_IN_PX = 4; /** * @ignore */ private unsubscribe = new Subject<void>(); /** * @ignore */ private _parentElement: HTMLElement; /** * @ignore */ private _onChange = (_: any): void => {}; /** * @ignore */ protected _onTouched = (): void => {}; /** * @ignore */ private clickListenerDisposeFct: Function; /** * @ignore */ private scrollListenerDisposeFct: Function; /** * Bandaid hack to prevent a weird focusout event bug happening when * `appendTo` is set and an item is selected. For *some* reason, the focusout * event is triggered multiple times when opening the list, which causes * unexpected behavior. * * This variable allows to temporarily "disables" the `handleFocusout` handler. * * FIXME: Find an actual solution to this problem instead of a bandaid hack. * @ignore */ private ignoreFocusout = false; /** * @ignore */ isOpen = false; /** * @ignore */ isFiltered = false; /** * @ignore */ selectedValue: AutocompleteOption; /** * @ignore */ filteredData: AutocompleteOption[]; /** * Input search text * @ignore */ searchText: string; /** * Index of currently selected suggestion. -1 if no suggestion is currently selected * @ignore */ focusIndex: number = -1; protected activeIndex = -1; /** * Id of currently selected item. Null if no suggestion is currently selected * @ignore */ protected focusedItemId: string | null = null; /** * Live zone content. It will be announced by assistive technologies everytime it is changed. * @ignore */ liveZoneContent = ''; /** * Dropdown icon name */ @Input() iconName = 'keyboard_arrow_down'; /** * Whether to show number of results or no */ @Input() showNumberOfResults = true; /** * No results message to display */ @Input() noResultMessage = 'No Results'; /** * Result message, formatted like `{numberOfResults} {resultsNumberMessage}` */ @Input() resultsNumberMessage = 'results'; /** * Whether to show number of results or no */ @Input() showNoResultsMessage = true; /** * Limit of results to show on search */ @Input() searchLimit?: number; /** * Selector that points to dom node where the list should be rendered */ @Input() appendTo: string; /** * Track by Function */ @Input() trackByFn: (index: number, option: AutocompleteOption) => any; /** * @ignore */ _data: AutocompleteOption[]; /** * Autocomplete data */ @Input() set data(value: AutocompleteOption[]) { this._data = value; this.updateList(); } get data(): AutocompleteOption[] { return this._data; } /** * Suggestion list text alternative for assistive technologies. */ @Input() listLabel: string; /** * Instructions on how to navigate the list. It is append after the input label. * @example "Use the UP / DOWN arrows to navigate within the suggestion list. Press Enter to select an option. On touch devices, use swipe to navigate and double tap to select an option" */ @Input() inputInstructions: string; /** * Emits value of searched value on input type */ @Output() search: EventEmitter<string> = new EventEmitter<string>(); /** * @ignore */ @ViewChild('input') inputRef: ElementRef; /** * @ignore */ @ViewChild('optionsList', { read: ElementRef }) optionsList: ElementRef; /** * Option items * @ignore */ @ViewChildren(ListItemComponent) selectOptions: QueryList<ListItemComponent>; /** * @ignore */ @ContentChild('njAutocompleteOptionLabel', { read: TemplateRef }) optionLabelTemplate: TemplateRef<any>; /** * @ignore */ @ContentChild('njAutocompleteSearchResults', { read: TemplateRef }) searchResultsTemplate: TemplateRef<any>; /** * @ignore */ @ContentChild('njAutocompleteNoResult', { read: TemplateRef }) noResultTemplate: TemplateRef<any>; constructor( private renderer: Renderer2, private elementRef: ElementRef<HTMLElement>, private cdr: ChangeDetectorRef, @Inject(DOCUMENT) private _document ) { super(); this.initScrollListener(); } /** * @ignore */ ngAfterContentInit() { super.ngAfterContentInit(); } /** * @ignore */ ngOnDestroy() { this.unsubscribe.next(); this.unsubscribe.complete(); this.clickListenerDisposeFct?.(); this.scrollListenerDisposeFct?.(); if (this.appendTo) { this.removeAppendedElementFromParent(); } } /** * Content of hint item and hidden. * @ignore */ private createResultsMessageContent() { const elements = this.filteredData; if (elements.length === 0) { return this.noResultMessage; } return `${elements.length} ${this.resultsNumberMessage}`; } /** * Update displayed suggestions and update live zone * @private */ private updateList(): void { if (!this.isFiltered || !this.searchText || Utils.isUndefinedOrNull(this._data)) { this.filteredData = this._data; } else { this.filteredData = this._data .filter((option) => Utils.normalizeAndSearchInText(option?.label, this.searchText)) .slice(0, this.searchLimit); } this.liveZoneContent = this.createResultsMessageContent(); this.processActiveOption(); } private processActiveOption() { this.activeIndex = this.filteredData.findIndex((item) => item.label === this.searchText); } get interactedItemIndex() { if (this.focusIndex !== -1) { return this.focusIndex; } return this.activeIndex !== -1 ? this.activeIndex : 0; } private scrollOnListOpening() { const element = this.selectOptions?.get(this.interactedItemIndex)?.el?.nativeElement; element.scrollIntoView({ block: 'nearest' }); } private appendAndComputeListPosition() { if (!this.appendTo || !this.optionsList?.nativeElement) { return; } const focusedEl = document.activeElement as HTMLElement; this._parentElement = this._document.querySelector(this.appendTo); this.computeListPosition(); this._parentElement.appendChild(this.optionsList.nativeElement); // The appendChild() call above might remove the focus from the currently // selected element so we restore the focus to where it was before the append. focusedEl?.focus(); } /** * @private */ private computeListPosition() { if (this.optionsList?.nativeElement && this.inputRef?.nativeElement) { const inputBoundingRect = this.inputRef?.nativeElement?.getBoundingClientRect(); if (inputBoundingRect) { this.optionsList.nativeElement.style = ` position: fixed; left: ${inputBoundingRect.left - this.LIST_OFFSET_IN_PX}px; top: ${inputBoundingRect.top + inputBoundingRect.height + this.LIST_OFFSET_IN_PX + this.INPUT_BORDER_IN_PX}px; min-width: ${inputBoundingRect.width + this.LIST_OFFSET_IN_PX * 2}px; transform: scaleY(1); opacity: 1; `; } } } /** * @ignore */ private removeAppendedElementFromParent() { if (this.optionsList?.nativeElement) { this.renderer.removeChild(this._parentElement, this.optionsList.nativeElement); } } /** * @ignore */ private initScrollListener() { this.scrollListenerDisposeFct = this.renderer.listen('window', 'scroll', (_: Event) => { if (this.appendTo && this.isOpen) { this.computeListPosition(); } }); } /** * @ignore */ getAdditionalClass(): string { return `nj-form-item--select nj-form-item--autocomplete${this.isOpen ? ' nj-form-item--open' : ''}`; } /** * 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.inputRef) { return; } this.isDisabled = isDisabled; } /** * Implemented as part of ControlValueAccessor. * @ignore */ writeValue(value: AutocompleteOption): void { this.selectedValue = value; this.searchText = value?.label ?? ''; if (this.inputRef) { this.inputRef.nativeElement.value = value?.label ?? ''; } this.cdr.markForCheck(); } /** Open the suggestion list. */ openList() { this.updateList(); this.isOpen = true; this.ignoreFocusout = true; setTimeout(() => { this.scrollOnListOpening(); if (this.appendTo) { this.appendAndComputeListPosition(); } this.ignoreFocusout = false; }); } /** Close the suggestion list. */ closeList() { this.isOpen = false; this.focusIndex = -1; this.activeIndex = -1; if (this.appendTo) { this.removeAppendedElementFromParent(); } this.cdr.markForCheck(); } /** * Toggle the suggestion list. * @ignore */ handleInputClick() { if (this.isOpen) { this.closeList(); } else { this.isFiltered = false; this.openList(); } } /** * Handle input change and save searchText * @ignore */ handleInputEvent(event: InputEvent): void { if (event?.data === '') { return; } this.searchText = (event?.target as HTMLInputElement)?.value; this.search.emit(this.searchText); const matchingOption = this._data.find((option) => option.label === this.searchText); if (matchingOption) { this.selectItem(matchingOption); } else { this.updateList(); setTimeout(() => { if (this.filteredData.length) { this.focusIndex = 0; this.focusFocusedOption(); } }); } } /** * @ignore */ getItemId(index: number) { return `${this.inputId}-item-${index}`; } /** * @ignore */ getListId(): string { return `${this.inputId}-list`; } /** * @ignore */ getInstructionsId(): string { return `${this.inputId}-instructions`; } /** * @ignore */ private focusFocusedOption() { const element = this.selectOptions?.get(this.focusIndex)?.el?.nativeElement; this.focusedItemId = element?.id; element.scrollIntoView({ block: 'nearest' }); } /** * @ignore */ private selectNextOption() { if (this.filteredData.length) { if (this.focusIndex !== -1) { const nextIndex = (this.focusIndex + 1) % this.filteredData.length; this.focusIndex = nextIndex; } else { this.focusIndex = this.interactedItemIndex; } this.focusFocusedOption(); } } /** * @ignore */ private selectPreviousOption() { if (this.filteredData.length) { const previousIndex = this.focusIndex === 0 ? this.filteredData.length - 1 : this.focusIndex - 1; this.focusIndex = previousIndex; this.focusFocusedOption(); } } /** * @ignore */ private unselectOption() { this.focusIndex = -1; this.focusedItemId = null; } /** * @ignore */ handleKeydownEvent(e: KeyboardEvent) { if (e.key === 'Tab') { // Ignore Tab key to not mess up with focusout event handler return; } switch (e.key) { case 'ArrowDown': e.preventDefault(); if (!this.isOpen) { this.isFiltered = false; this.openList(); this.focusIndex = this.interactedItemIndex; this.focusFocusedOption(); } else { this.selectNextOption(); } break; case 'ArrowUp': e.preventDefault(); if (!this.isOpen) { this.isFiltered = false; this.openList(); } this.focusIndex = this.interactedItemIndex; this.selectPreviousOption(); break; case 'Escape': e.preventDefault(); if (this.isOpen) { this.closeList(); } break; case 'Enter': if (this.isOpen && this.focusIndex !== -1) { this.selectItem(this.filteredData[this.focusIndex]); } break; default: // Ignore non-character keys and shortcut combinations const keyIsPrintable = (e.key.length === 1 || e.key === 'Backspace') && !e.metaKey && !e.altKey && !e.ctrlKey; if (keyIsPrintable) { this.isFiltered = true; this.unselectOption(); if (!this.isOpen) { this.openList(); } setTimeout(() => { const matchingOption = this.getMatchinOption(); this._onChange(matchingOption); this.cdr.markForCheck(); }); } } } /** * @ignore */ private getMatchinOption() { const filteredData = this._data?.filter((option) => this.searchText === option.label); return filteredData?.[0]; } /** * @ignore */ selectItem(option: AutocompleteOption) { this.closeList(); if (this.inputRef) { this.inputRef.nativeElement.value = option.label; } this.searchText = option.label; this.selectedValue = option; this.unselectOption(); this._onChange(option); this.cdr.markForCheck(); } /** * Closes the suggestion list if the focus is moved outside of the autocomplete. * @ignore */ handleFocusout(e: FocusEvent) { if (this.ignoreFocusout) { return; } if ( !this.elementRef?.nativeElement.contains(e.relatedTarget as Node) && !this.optionsList?.nativeElement.contains(e.relatedTarget as Node) ) { this.closeList(); } } /** * @ignore * @param index * @param option */ trackByOption(index: number, option: AutocompleteOption) { if (this.trackByFn) { return this.trackByFn(index, option); } return option; } }