@engie-group/fluid-design-system-angular
Version:
Fluid Design System Angular
694 lines (606 loc) • 15.8 kB
text/typescript
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';
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
*/
iconName = 'keyboard_arrow_down';
/**
* Whether to show number of results or no
*/
showNumberOfResults = true;
/**
* No results message to display
*/
noResultMessage = 'No Results';
/**
* Result message, formatted like `{numberOfResults} {resultsNumberMessage}`
*/
resultsNumberMessage = 'results';
/**
* Whether to show number of results or no
*/
showNoResultsMessage = true;
/**
* Limit of results to show on search
*/
searchLimit?: number;
/**
* Selector that points to dom node where the list should be rendered
*/
appendTo: string;
/**
* Track by Function
*/
trackByFn: (index: number, option: AutocompleteOption) => any;
/**
* @ignore
*/
_data: AutocompleteOption[];
/**
* Autocomplete data
*/
set data(value: AutocompleteOption[]) {
this._data = value;
this.updateList();
}
get data(): AutocompleteOption[] {
return this._data;
}
/**
* Suggestion list text alternative for assistive technologies.
*/
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"
*/
inputInstructions: string;
/**
* Emits value of searched value on input type
*/
search: EventEmitter<string> = new EventEmitter<string>();
/**
* @ignore
*/
inputRef: ElementRef;
/**
* @ignore
*/
optionsList: ElementRef;
/**
* Option items
* @ignore
*/
selectOptions: QueryList<ListItemComponent>;
/**
* @ignore
*/
optionLabelTemplate: TemplateRef<any>;
/**
* @ignore
*/
searchResultsTemplate: TemplateRef<any>;
/**
* @ignore
*/
noResultTemplate: TemplateRef<any>;
constructor(
private renderer: Renderer2,
private elementRef: ElementRef<HTMLElement>,
private cdr: ChangeDetectorRef,
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;
}
}