UiPath/angular-components

View on GitHub
projects/angular/components/ui-suggest/src/ui-suggest.component.ts

Summary

Maintainability
D
2 days
Test Coverage
A
91%
import cloneDeep from 'lodash-es/cloneDeep';
import isEqual from 'lodash-es/isEqual';
import {
    animationFrameScheduler,
    BehaviorSubject,
    combineLatest,
    merge,
    Observable,
    of,
    Subject,
    Subscription,
} from 'rxjs';
import {
    debounceTime,
    delay,
    distinctUntilChanged,
    filter,
    finalize,
    map,
    observeOn,
    retry,
    skip,
    startWith,
    switchMap,
    takeUntil,
    tap,
} from 'rxjs/operators';

import { LiveAnnouncer } from '@angular/cdk/a11y';
import { ListRange } from '@angular/cdk/collections';
import { ConnectedPosition } from '@angular/cdk/overlay';
import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
import {
    AfterViewInit,
    ChangeDetectionStrategy,
    ChangeDetectorRef,
    Component,
    ContentChild,
    ElementRef,
    EventEmitter,
    HostBinding,
    Input,
    isDevMode,
    NgZone,
    OnChanges,
    OnDestroy,
    OnInit,
    Optional,
    Output,
    Self,
    SimpleChanges,
    TemplateRef,
    TrackByFunction,
    ViewChild,
    ViewEncapsulation,
} from '@angular/core';
import {
    FormGroupDirective,
    NgControl,
    NgForm,
} from '@angular/forms';
import { ErrorStateMatcher } from '@angular/material/core';
import { MatFormFieldControl } from '@angular/material/form-field';
import { VirtualScrollItemStatus } from '@uipath/angular/directives/ui-virtual-scroll-range-loader';

import {
    ISuggestValue,
    ISuggestValues,
    SuggestDirection,
    SuggestMaxSelectionConfig,
} from './models';
import { UI_SUGGEST_ANIMATIONS } from './ui-suggest.animations';
import { UiSuggestIntl } from './ui-suggest.intl';
import { UiSuggestMatFormFieldDirective } from './ui-suggest.mat-form-field';
import {
    caseInsensitiveCompare,
    generateLoadingInitialCollection,
    inMemorySearch,
    mapInitialItems,
    resetUnloadedState,
    setLoadedState,
    setPendingState,
    toSuggestValue,
} from './utils';

export const DEFAULT_SUGGEST_DEBOUNCE_TIME = 300;
export const DEFAULT_SUGGEST_DRILLDOWN_CHARACTER = ':';
export const MAT_CHIP_INPUT_SELECTOR = '.mat-mdc-chip-grid input';

/**
 * A form compatible `dropdown` packing `lazy-loading` and `virtual-scroll`.
 *
 * @ignore
 * @export
 */
@Component({
    selector: 'ui-suggest',
    styleUrls: ['./ui-suggest.component.scss'],
    templateUrl: './ui-suggest.component.html',
    encapsulation: ViewEncapsulation.None,
    changeDetection: ChangeDetectionStrategy.OnPush,
    providers: [
        NgForm,
        FormGroupDirective,
        {
            provide: MatFormFieldControl,
            useExisting: UiSuggestComponent,
        },
    ],
    animations: [UI_SUGGEST_ANIMATIONS.transformMenuList],
})
export class UiSuggestComponent extends UiSuggestMatFormFieldDirective
    implements
    OnDestroy,
    OnInit,
    OnChanges,
    AfterViewInit {

    get inDrillDownMode() {
        return !!this.drillDown && this.inputControl.value.includes(this.drillDownCharacter);
    }

    /**
     * Configure if the component is `disabled`.
     *
     */
    @HostBinding('class.disabled')
    @Input()
    get disabled() {
        return this._disabled$.value;
    }
    set disabled(value) {
        if (this._disabled$.value === !!value) { return; }

        this._disabled$.next(!!value);
        if (
            value &&
            this.isOpen
        ) {
            this.close(false);
        }

        this._cd.markForCheck();
        this.stateChanges.next();
    }

    /**
     * Configure if the component allows expandable items
     *
     */
    @HostBinding('class.drill-down')
    @Input()
    get drillDown() {
        return this._drillDown;
    }
    set drillDown(value) {
        this._drillDown = !!value;
    }

    /**
     * Divider character for drilldown logic
     */
    @Input()
    drillDownCharacter = DEFAULT_SUGGEST_DRILLDOWN_CHARACTER;

    /**
     * Configure if the component is `readonly`.
     *
     */
    @HostBinding('class.readonly')
    @Input()
    get readonly() {
        return this._readonly;
    }
    set readonly(value) {
        this._readonly = !!value;
        if (
            value &&
            this.isOpen
        ) {
            this.close(false);
        }
        this.stateChanges.next();
        this._cd.detectChanges();
    }

    /**
     * Set the element in high density state.
     *
     */
    @HostBinding('class.ui-suggest-state-high-density')
    @Input()
    hasHighDensity = false;

    /**
     * By default the onOpen fetchStrategy prevents additional requests if closed.
     * This allows you to bypass that check and update even if closed.
     */
    @Input()
    ignoreOpenOnFetch = false;

    /**
     * Controls whether to use the default custom value template or use the custom item template
     *
     */
    @Input()
    applyItemTemplateToCustomValue = false;

    /**
     * A list of options that will be presented at the top of the list.
     *
     */
    @Input()
    get headerItems() {
        return this.loading$.value || !!this.inputControl.value.trim()
            ? []
            : this._headerItems;
    }
    set headerItems(value: ISuggestValue[] | null) {
        if (!value || isEqual(value, this._headerItems)) { return; }

        this._headerItems = this._sortItems(value)
            .map(r => ({
                ...r,
                loading: VirtualScrollItemStatus.loaded,
            }));
        this._checkUnsuportedScenarios();
    }

    /**
     * If true, the item list will render open and will not close on selection
     *
     */
    @Input()
    alwaysExpanded = false;

    /**
     * If true, component will always render the list upfront
     */
    @Input()
    expandInline = false;

    /**
     * If true, component wil place the dropdown over the input
     */
    @Input()
    forceDisplayDropdownOverInput = true;

    /**
     * Configure if the component allows multi-selection.
     *
     */
    @Input()
    get multiple() {
        return this._multiple;
    }
    set multiple(multiple) {
        if (this._multiple !== multiple) {
            if (!multiple) {
                this._deselectValuesFrom(1);
                this.registerChange(this.value);
            }
            this._multiple = multiple;
            this._cd.detectChanges();
        }
    }

    /**
     * The `dropdown` item list.
     *
     */
    @Input()
    get items() {
        return this._items;
    }
    set items(items: ISuggestValue[]) {
        if (!items || isEqual(items, this._items)) { return; }

        this._lastSetItems = cloneDeep(items);

        if (this.searchable) {
            this.fetch(this.inputControl.value);
        }

        this._items = this._sortItems(items)
            .map(r => ({
                ...r,
                loading: VirtualScrollItemStatus.loaded,
            }));
    }

    /**
     * Configure the direction in which to open the overlay: `up` or `down'.
     *
     */
    @Input()
    set direction(value: SuggestDirection) {
        if (this._direction === value) { return; }
        this._items.reverse();
        this._direction = value;
        this._checkUnsuportedScenarios();
    }
    get direction() {
        return this._direction;
    }

    /**
     * Configure if the dropdown has `search` enabled.
     *
     */
    @Input()
    get searchable() {
        return !!this.searchSourceFactory;
    }
    set searchable(searchable) {
        if (!searchable) {
            this.searchSourceFactory = void 0;
            return;
        }

        if (this.searchSourceFactory) {
            return;
        }
        this.searchSourceFactory = (searchTerm = '') => inMemorySearch(searchTerm, this._lastSetItems);
    }

    /**
     * Reference for custom item template
     *
     */
    @ContentChild(TemplateRef, { static: true })
    itemTemplate: TemplateRef<any> | null = null;

    /**
     * Computes the current tooltip value.
     *
     */
    get tooltip() {
        if (
            !this.isOpen &&
            this._hasValue
        ) {
            return this._getValueSummary(true);
        }

        return null;
    }

    /**
     * Determines if the `custom value` option should be `displayed`.
     *
     */
    get isCustomValueVisible(): boolean {
        if (
            !this._hasCustomValue$.value ||
            this.loading$.value
        ) {
            return false;
        }

        return !this.multiple || !this._value.some(v => v.text === this.inputControl.value.trim()) || this.isCustomValueAlreadySelected;
    }

    get isCustomHeaderItemsVisible(): boolean {
        return !(this.loading$.value || !this.headerItems!.length);
    }

    /**
     * Retrieves the currently `rendered` items.
     *
     */
    get renderItems() {
        return this.loading$.value
            ? []
            : this._hasCustomValue$.value && !this.isDown
                // FIXME: hack
                ? [...this.items, false as unknown as ISuggestValue]
                : this.items;
    }

    /**
     * Configure if the user is allowed to select `custom values`.
     *
     * @deprecated
     */
    @Input()
    get enableCustomValue() {
        return this._enableCustomValue;
    }
    set enableCustomValue(value) {
        this._enableCustomValue = !!value;
        this._checkUnsuportedScenarios();
    }

    /**
     * Configure if the dropdown is in a `loading` state.
     *
     */
    @Input()
    set loading(value: boolean) {
        this.loading$.next(value);
    }

    /**
     * Render an additional info message if a specific count of items is rendered
     * Useful in case search results are capped and the user needs to adjust the query
     */
    @Input()
    searchableCountInfo?: { count: number; message: string };

    /**
     * Configure if the selected value shown should respect the template in dropdown
     *
     */
    @Input()
    displayTemplateValue = false;

    /**
     * Determines if there are no results to display.
     *
     */
    get hasNoResults() {
        return !this.loading$.value && !this.items.length;
    }

    /**
     * @ignore
     */
    get isCustomValueAlreadySelected() {
        if (
            !this._hasCustomValue$.value ||
            this.loading$.value
        ) {
            return false;
        }

        return this.isItemSelected(toSuggestValue(this.inputControl.value.trim()));
    }

    /**
     * Computes the `viewport` max-height.
     *
     */
    get viewportMaxHeight() {
        if (!this.isOpen) { return 0; }

        if (this.expandInline && this._height$.value) {
            return this._height$.value;
        }

        const actualCount = this.renderItems.filter(Boolean).length + (this.enableCustomValue ?
            (Number(this.isCustomValueVisible)) : (this.headerItems!.length));

        if (actualCount === 0) {
            return this.baseSize + Number(!!this.headerItems!.length);
        }

        const displayedCount = Math.min(this.displayCount, Math.max(actualCount, 1));
        return this.itemSize * displayedCount + Number(!!this.headerItems!.length);
    }

    private get _isOnCustomValueIndex() {
        if (this.headerItems!.length) {
            return this.activeIndex < this.headerItems!.length;
        }

        return this.enableCustomValue &&
            !!this.inputControl.value.trim() &&
            (
                this.activeIndex === -1 ||
                this.activeIndex === this._items.length
            );
    }

    private get _itemLowerBound() {
        return this._hasCustomValue$.value && this.isDown ? -1 : 0;
    }

    private get _itemUpperBound() {
        const headerItemsLength = this.headerItems!.length ?? 0;
        const itemsLength = this.items.length + headerItemsLength;

        return itemsLength + (this._hasCustomValue$.value && !this.isDown ? 0 : -1);
    }

    private get _fetchCount() {
        return this._isLazyMode ? this.displayCount * 2 : this.displayCount;
    }

    /**
     * A search stream factory, generally used to retrieve data from the server when a user searches.
     * By `default`, a search factory is generated that does an `in-memory` lookup if `searchable` is set to `true`.
     *
     */
    @Input()
    searchSourceFactory?: (searchTerm?: string, fetchCount?: number, skip?: number) => Observable<ISuggestValues<any>>;

    /**
     * Configure the `searchSourceStrategy` for requesting data using searchSourceFactory:
     * `default` - need total count
     * `lazy` - items will be fetched only when reaching bottom of the list (no need of total count)
     */
    @Input()
    searchSourceStrategy: 'default' | 'lazy' = 'default';

    /**
     * A display value factory, generally used to compute the display value for multiple items.
     * By `default`, a display value factory is generated that does an array.join.
     *
     */
    @Input()
    displayValueFactory?: (value?: ISuggestValue[]) => string;

    @Input()
    customValueLabelTranslator!: (value: string) => string;

    /**
     * Configure the `fetchStrategy` for requesting data using searchSourceFactory
     * `eager` - makes calls to searchSourceFactory onInit
     * `onOpen` - makes calls to searchSourceFactory onOpen
     *
     */
    @Input()
    set fetchStrategy(strategy: 'eager' | 'onOpen') {
        if (strategy === this._fetchStrategy$.value) { return; }

        this._fetchStrategy$.next(strategy);
    }

    /**
     * Configure the minimum number of characters that triggers the searchSourceFactory call
     * This will have priority over the fetch strategy if set.
     *
     */
    @Input()
    get minChars() {
        return this._minChars;
    }
    set minChars(value: number) {
        this._minChars = value;
        this._checkUnsuportedScenarios();
    }

    /**
     * Configure the `control` width.
     *
     */
    @Input()
    get width() {
        return !this._width ?
            this.expandInline ? '100%' : this.suggestContainerWidth + 'px' :
            this._width;
    }

    set width(value: string) {
        this._width = value;
    }
    /**
     * Configure the `maximum` search length.
     *
     */
    @Input()
    maxLength?: number;
    /**
     * The search event debounce interval in `ms`.
     *
     */
    @Input()
    debounceTime = DEFAULT_SUGGEST_DEBOUNCE_TIME;
    /**
     * The maximum number of items rendered in the viewport.
     *
     */
    @Input()
    get displayCount() {
        return this._displayCount ?? 10;
    }

    set displayCount(value: number) {
        if (!this.expandInline) {
            this._displayCount = value;
        }
    }
    /**
     * Configure if the component allows selection clearing.
     *
     */
    @Input()
    clearable = true;
    /**
     * Configure the `default` selected value.
     *
     */
    @Input()
    defaultValue = '';
    /**
     * Configure if the tooltip should be disabled.
     *
     */
    @Input()
    disableTooltip = false;

    /**
     * Use compact summary info instead of chips
     *
     */
    @Input()
    compact = false;

    /**
     * The template to use for compact summary
     *
     */
    @Input()
    compactSummaryTemplate?: TemplateRef<any>;

    /**
     * The config used to describe suggest when the maximum number of selected items is reached.
     *
     */
    @Input()
    maxSelectionConfig: SuggestMaxSelectionConfig = {
        count: Infinity,
        itemTooltip: '',
        footerMessage: '',
    };
    /**
     * Emits `once` when `data` is retrieved for the `first time`.
     *
     */
    @Output()
    sourceInitialized = new EventEmitter<ISuggestValue[]>();

    /**
     * Emits `every` time item data is retrieved.
     *
     */
    @Output()
    sourceUpdated = new EventEmitter<ISuggestValue[]>();

    /**
     * Emits when the overlay is hidden (dropdown close).
     *
     */
    @Output()
    closed = new EventEmitter<void>();

    /**
     * Emits when an item is selected.
     *
     */
    @Output()
    itemSelected = new EventEmitter<ISuggestValue>();

    /**
     * Emits when the overlay is displayed (dropdown open).
     *
     */
    @Output()
    opened = new EventEmitter<void>();

    /**
     * Emits on losing or receiving focus.
     *
     */
    @Output()
    focusEvent = new EventEmitter<FocusEvent>();

    /**
     * @ignore
     */
    VirtualScrollItemStatus = VirtualScrollItemStatus;
    /**
     * Configures the dropdown open state.
     *
     * @ignore
     */
    set isOpen(isOpen: boolean) {
        if (this._isOpen$.value === isOpen) { return; }

        this._isOpen$.next(isOpen);
    }
    get isOpen() {
        return this._isOpen$.value;
    }
    /**
     * The current selected item index.
     *
     * @ignore
     */
    activeIndex = -1;
    /**
     * The component loading state source.
     *
     * @ignore
     */
    loading$ = new BehaviorSubject(false);
    /**
     * Stream that triggers focusing.
     *
     * @ignore
     */
    focus$ = new Subject<boolean>();

    upPosition: ConnectedPosition = {
        originX: 'start',
        originY: 'bottom',
        overlayX: 'start',
        overlayY: 'bottom',
        offsetY: 3,
    };

    downPosition: ConnectedPosition = {
        originX: 'start',
        originY: 'top',
        overlayX: 'start',
        overlayY: 'top',
        offsetY: -3,
    };
    dropdownPosition: ConnectedPosition[] = [];

    suggestContainerWidth = 0;

    @ViewChild('suggestContainer') suggestContainer!: ElementRef;
    @ViewChild('displayContainer') displayContainer?: ElementRef;

    @ViewChild(CdkVirtualScrollViewport)
    protected set _virtualScrollerQuery(value: CdkVirtualScrollViewport) {
        if (!value || this._virtualScroller === value) { return; }

        this._virtualScroller = value;

        if (this.expandInline) {
            this._matListElement = this._virtualScroller?.getElementRef().nativeElement.parentElement!;
            this._observer?.observe(this._matListElement);
        }

        this._virtualScroller!
            .scrolledIndexChange
            .pipe(
                skip(1),
                takeUntil(this._destroyed$),
            )
            .subscribe(start => {
                this._visibleRange = {
                    start,
                    end: start + this.displayCount,
                };
            });
    }

    private _hasCustomValue$ = new BehaviorSubject(false);
    private _reset$ = new Subject<void>();

    private get _isOpenDisabled() {
        return this.isOpen ||
            this.disabled ||
            this.readonly;
    }

    private get _hasValue() {
        return this.value &&
            !this.empty;
    }

    private _readonly = false;

    private _displayCount?: number;

    private _width?: string;

    private _observer!: ResizeObserver;
    private _suggestContainerObserver!: ResizeObserver;

    private _height$ = new BehaviorSubject(0);

    private _searchSub?: Subscription;

    private _disabled$ = new BehaviorSubject(false);
    private _multiple = false;
    private _lastSetItems: ISuggestValue[] = [];
    private _enableCustomValue = false;
    private _minChars = 0;

    private _triggerViewportRefresh$ = new BehaviorSubject<null>(null);
    private _destroyed$ = new Subject<void>();
    private _scrollTo$ = new Subject<number>();
    private _rangeLoad$ = new Subject<ListRange>();
    private _fetchStrategy$ = new BehaviorSubject<'eager' | 'onOpen'>('eager');
    private _isOpen$ = new BehaviorSubject(false);

    private _headerItems: ISuggestValue[] = [];
    private _virtualScroller?: CdkVirtualScrollViewport;
    private _visibleRange = {
        start: Number.NEGATIVE_INFINITY,
        end: Number.POSITIVE_INFINITY,
    };

    private _inputChange$!: Observable<string>;
    private _drillDown = false;
    private _lazyLoadLastArgument: any[] = ['', 0, 0];

    private _matListElement?: HTMLElement;

    /**
     * @ignore
     */
    constructor(
        elementRef: ElementRef,
        cd: ChangeDetectorRef,
        errorStateMatcher: ErrorStateMatcher,
        parentForm: NgForm,
        parentFormGroup: FormGroupDirective,
        @Optional()
        @Self()
        public ngControl: NgControl,
        @Optional()
        public intl: UiSuggestIntl,
        private _liveAnnouncer: LiveAnnouncer,
        private _zone: NgZone,
    ) {
        super(
            elementRef,
            errorStateMatcher,
            parentForm,
            parentFormGroup,
            cd,
            ngControl,
        );
        this._initResizeObserver();

        this._height$.subscribe(heightValue => {
            if (this.expandInline) {
                this._displayCount = Math.round(heightValue / this.itemSize);
            }
        });

        this.intl = this.intl || new UiSuggestIntl();
        this.intl
            .changes
            .pipe(takeUntil(this._destroyed$))
            .subscribe(() => cd.detectChanges());
        this.customValueLabelTranslator = this.customValueLabelTranslator || this.intl.customValueLabel;
    }

    /**
     * Configure if each individual chip can be removed
     *
     */
    @Input()
    canRemoveChip: (value: ISuggestValue) => boolean
        = () => !this.readonly;
    /**
     * @ignore
     */
    ngOnInit() {
        if (this.alwaysExpanded || this.expandInline) {
            this.open();
        }

        this._visibleRange = {
            start: 0,
            end: this.displayCount,
        };

        this._initOverlayPositions();
        this.dropdownPosition = [this.direction && this.direction === 'up' ? this.upPosition : this.downPosition];

        this._inputChange$ = combineLatest([
            this.inputControl.valueChanges.pipe(
                startWith(''),
                map((v = '') => v.trim()),
                distinctUntilChanged(),
                filter(v => v.length >= this.minChars),
                tap(v => v && this.multiple && this.open()),
                tap(this._setLoadingState),
                debounceTime(this.debounceTime),
                filter(_ => !!this.searchSourceFactory),
            ),
            this._disabled$.pipe(
                filter(v => !v),
            ),
            this._fetchStrategy$
                .pipe(
                    switchMap(strategy => {
                        switch (strategy) {
                            case 'onOpen':
                                return this._isOpen$.pipe(filter(o => !!o));
                            case 'eager':
                                return of(strategy);
                        }
                    }),
                ),
        ]).pipe(
            map(([value]) => value as any),
        );

        merge(
            this._reset$.pipe(
                map(_ => ''),
                tap(_ => !!this.inputControl.value.trim() && this.inputControl.setValue('')),
                tap(this._setLoadingState),
            ),
            this._inputChange$,
        ).pipe(
            takeUntil(this._destroyed$),
        ).subscribe(this.fetch);

        this._scrollTo$
            .pipe(
                delay(0),
                observeOn(animationFrameScheduler),
                filter(_ => this.isOpen),
                takeUntil(this._destroyed$),
            )
            .subscribe(this._virtualScrollTo);
    }

    ngOnChanges(changes: SimpleChanges) {
        if (changes.direction) {
            this.dropdownPosition = [this._getDropdownPositionAccordingToDirection()];
        }

        if (this.searchSourceStrategy === 'lazy' && this.direction === 'up') {
            throw new Error('Currently support only down direction for lazy mode');
        }

        if (this.searchSourceStrategy === 'lazy' && !this.searchSourceFactory) {
            throw new Error('Should provide a searchSourceFactory for lazyMode');
        }

        const { displayPriority } = changes;

        if (displayPriority?.currentValue !== displayPriority?.previousValue) {
            this._items = this._sortItems(this._items);
        }
    }

    /**
     * @ignore
     */
    ngOnDestroy() {
        this._destroyed$.next();
        this._destroyed$.complete();
        this.stateChanges.complete();
        this.selected.complete();
        this.deselected.complete();
        this.sourceUpdated.complete();
        this.closed.complete();
        this.opened.complete();

        if (this._matListElement) {
            this._observer?.unobserve(this._matListElement);
        }

        if (this.isOpen) {
            this._suggestContainerObserver.unobserve(this.suggestContainer.nativeElement);
        }

        if (!this.sourceInitialized.closed) {
            this.sourceInitialized.complete();
        }
    }

    /**
     * @ignore
     */
    ngAfterViewInit() {
        combineLatest([
            this._triggerViewportRefresh$,
            this._rangeLoad$,
        ]).pipe(
            map(([, range]) => range),
            takeUntil(this._destroyed$),
        ).subscribe(this._rangeLoad);
    }

    /**
     * Computed index offset in items list when prepending header items
     *
     * @param index
     * @returns
     */
    computedItemsOffset(index: number) {
        return index + this.headerItems!.length;
    }

    /**
     * Is called every time a new range needs to be loaded.
     *
     * @ignore
     */
    rangeLoad = (range: ListRange) => this._rangeLoad$.next(range);

    /**
     * Disable state hook for the `form`.
     *
     * @param isDisabled The truth of of the `disabled` state.
     * @ignore
     */
    setDisabledState(isDisabled: boolean) {
        this.disabled = isDisabled;
    }

    /**
     * Handles `click` events on the `form-control` container.
     *
     * @ignore
     */
    onContainerClick(event: MouseEvent) {
        if (
            !this.focused &&
            !this._isOpenDisabled
        ) {
            this.open();
            this.focus$.next(true);
            this.preventDefault(event);
        }
    }

    /**
     * Notifies focus changes to the `form`.
     *
     * @ignore
     */
    onBlur(event: FocusEvent) {
        this.focusEvent.emit(event);
        this._focusChanged(this.isOpen);
    }

    /**
     * Notifies focus changes to the `form`.
     *
     * @ignore
     */
    onFocus(event: FocusEvent) {
        this.focusEvent.emit(event);
        this._focusChanged(true);
    }

    /**
     * Toggle the dropdown state (opened/closed);
     *
     */
    toggle() {
        // eslint-disable-next-line @typescript-eslint/no-unused-expressions
        this.isOpen ? this.close() : this.open();
    }

    /**
     * Opens the dropdown.
     *
     */
    open() {
        if (this._isOpenDisabled) { return; }
        this.isOpen = true;
        this.opened.emit();
        this._focusChanged(true);

        const [value] = this.value;

        if (
            this.enableCustomValue &&
            value &&
            value.isCustom &&
            !this.multiple
        ) {
            this.inputControl.setValue(value.text);
        }

        this._setAndScrollToActiveIndex(value);

        if (!this.loading$.value) {
            this._announceNavigate();
        }

        if (!this.expandInline) {
            this._suggestContainerObserver.observe(this.suggestContainer.nativeElement);
        }
    }

    /**
     * Closes the dropdown.
     *
     * @param [refocus=true] If the dropdown should be focused after closing.
     */
    close(refocus = true) {
        if (this.alwaysExpanded || this.expandInline || !this.isOpen) { return; }

        this._suggestContainerObserver.unobserve(this.suggestContainer.nativeElement);

        if (
            (this._isOnCustomValueIndex && !this.headerItems!.length) &&
            !this.loading$.value &&
            !this.multiple
        ) {
            this._clearSelection();
            this._pushEntry(toSuggestValue(this.inputControl.value.trim(), true));
        }

        this.registerTouch();
        this.clear();

        this.isOpen = false;
        this.activeIndex = -1;
        this._visibleRange = {
            start: Number.NEGATIVE_INFINITY,
            end: Number.POSITIVE_INFINITY,
        };
        this.closed.emit();

        this.focus$.next(refocus);

        if (!refocus) {
            this._focusChanged(false);
        }
    }

    /**
     * Resets the component state.
     *
     */
    reset() {
        this._reset$.next();
    }

    /**
     * Removes the active dropdown selection.
     *
     * @param [ev] `Mouse` or `Keyboard`.
     */
    removeSelection(ev?: Event | KeyboardEvent | MouseEvent) {
        if (!this.clearable) { return; }

        this.preventDefault(ev);
        this._clearSelection();
        this.selected.emit();
        this.registerTouch();
        this.registerChange(this.value);

        if (this.inDrillDownMode) {
            this.inputControl.setValue('');
            return;
        }
        this.close(false);
    }

    /**
     * Navigates through the items by looking up the next focused / active index.
     *
     * @param increment The increment that should be applied to the current index.
     * @param [ev] The navigation trigger event.
     * @ignore
     */
    navigate(increment: number, ev?: Event) {
        this.preventDefault(ev);

        if (
            this._cantNavigate(increment)
        ) { return; }

        const [value] = this.value;
        if (
            value &&
            this.activeIndex === this._itemLowerBound - 1
        ) {
            const headerItemIndex = this.headerItems!.findIndex(v => v.id === value.id);
            this.activeIndex = headerItemIndex !== -1
                ? headerItemIndex
                : this.computedItemsOffset(this._items.findIndex(v => v.id === value.id));
        }

        this._safeCycleIncrement(increment);

        this._announceNavigate();
        this._scrollTo$.next(this.activeIndex);
    }

    /**
     * Selects the current active item.
     *
     * @ignore
     */
    setSelectedItem() {
        if (this.loading$.value) { return; }

        if (this._isOnCustomValueIndex && !this.headerItems!.length) {
            this.updateValue(this.inputControl.value.trim(), !this.multiple);
            return;
        }

        this._selectActiveItem(!this.multiple);
    }

    /**
     * Determines if the provided `item` is currently selected.
     *
     * @param [item] The `item` that needs to be checked.
     * @returns If the provided `item` is selected.
     */
    isItemSelected(item?: ISuggestValue) {
        return !!item &&
            (!!this.value.find(v => v.id === item.id) ?? !!this.headerItems!.find(v => v.id === item.id));
    }

    /**
     * Updates the component value.
     *
     * @param inputValue The value that needs to be selected.
     * @param [closeAfterSelect=true] If the dropdown should close after the value is selected.
     * @param [refocus=true] If the search input should regain focus after selection.
     */
    // eslint-disable-next-line complexity
    updateValue(inputValue: ISuggestValue | string, closeAfterSelect = true, refocus = true) {
        let value = toSuggestValue(inputValue, this._isOnCustomValueIndex);
        if (this.maxSelectionConfig.count <= this.value.length && !this.isItemSelected(value)) { return; }
        if (value.loading !== VirtualScrollItemStatus.loaded || value.disabled === true) { return; }

        this.itemSelected.emit(value);

        if (this.inDrillDownMode) {
            value = {
                ...value,
                text: `${this.inputControl.value}${value.text}`,
            };
        }

        const isExpandable = this.searchable && this.drillDown && !!value.expandable;

        if (isExpandable) {
            this.inputControl.setValue(`${value.text}:`);
            return;
        }

        const isItemSelected = this.isItemSelected(value);

        if (!isItemSelected && value) {
            if (!this.multiple) {
                this._clearSelection();
            }
            this._pushEntry(value);

            this._announceSelectStatus(value.text, true);
        }

        if (value && this.multiple && !this.compact) {
            if (this.inputControl.value) {
                this.inputControl.setValue('');
            }
            this._focusChipInput();
        }

        const alreadySelectedNormalValue = this.multiple &&
            isItemSelected &&
            !!value &&
            !value.isCustom;

        if (alreadySelectedNormalValue) {
            this._removeEntry(value);

            this._announceSelectStatus(value.text, false);
        }

        if (closeAfterSelect) {
            this.close(refocus);
        }
    }

    /**
     * @ignore
     */
    preventDefault(ev?: Event) {
        if (!ev || this._isCheckbox(ev?.target)) { return; }
        ev.preventDefault();
        ev.stopImmediatePropagation();
    }

    /**
     * Triggers a refetch of data, calling the `searchSourceFactory` with the provided search term.
     *
     * @param searchValue The search value that should be used for the `fetch`.
     */
    fetch = (searchValue = '') => {
        if (!this.searchSourceFactory ||
            this._fetchStrategy$.value === 'onOpen' &&
            !this.ignoreOpenOnFetch &&
            !this.isOpen
        ) {
            return;
        }

        this.loading$.next(true);

        if (this._searchSub) { this._searchSub.unsubscribe(); }
        this._searchSub = this.searchSourceFactory(searchValue, this._fetchCount)
            .pipe(
                tap(this._setInitialItems),
                map(this._findItemIndex(searchValue)),
                tap(this._checkCustomValue(searchValue)),
                tap(this._setActiveIndex),
                takeUntil(this._destroyed$),
            ).subscribe({
                next: _ => {
                    this.loading$.next(false);
                    this._scrollToFirst();
                    this._cd.detectChanges();
                },
                error: (error) => {
                    if (isDevMode()) {
                        console.warn('UiSuggest fetch failed, more info: ', error);
                    }

                    this.loading$.next(false);
                    this._items = [];
                },
            });
    };

    /**
     * `NgFor` track method.
     *
     * @ignore
     */
    trackById: TrackByFunction<ISuggestValue> = (_: number, { id }: ISuggestValue) => id;

    deselectItem(option: ISuggestValue) {
        this._removeEntry(option);
    }

    backspaceBehavior() {
        if (!this.inputControl.value.trim().length) {
            this.close();
        }
    }

    computeDisplayValue() {
        return this.value.length > 0
            ? this._getValueSummary()
            : this.defaultValue;
    }

    onOptionsDropdownTabPressed() {
        if (this.isOpen && !this.expandInline) {
            this.displayContainer?.nativeElement.focus();
        }
        this.close(false);
    }

    private _getDropdownPositionAccordingToDirection() {
        return this.direction === 'up' ? this.upPosition : this.downPosition;
    }

    private _initOverlayPositions() {
        const mustBePlacedBelowTheInput = !this.forceDisplayDropdownOverInput || this.multiple;

        // We need this because we want to show the dropdown below the mat-form-field and not below the ui-suggest component
        if (this.isFormControl) {
            this._setDropdownOffset(mustBePlacedBelowTheInput);
        }

        if (mustBePlacedBelowTheInput) {
            this._setDropdownOrigin();
        }
    }

    private _setDropdownOrigin() {
        this.upPosition = {
            ...this.upPosition,
            originX: 'start',
            originY: 'top',
        };
        this.downPosition = {
            ...this.downPosition,
            originX: 'start',
            originY: 'bottom',
        };

    }

    private _setDropdownOffset(mustBePlacedBesideTheInput: boolean) {
        this.upPosition = {
            ...this.upPosition,
            offsetX: -1,
            offsetY: mustBePlacedBesideTheInput ? -10 : 11,
        };
        this.downPosition = {
            ...this.downPosition,
            offsetX: -1,
            offsetY: mustBePlacedBesideTheInput ? 10 : -11,
        };
    }

    private _initResizeObserver() {
        this._observer = new ResizeObserver(entries => {
            this._zone.run(() => {
                this._height$.next(entries?.[0]?.contentRect.height);
                this._cd.markForCheck();
            });
        });

        this._suggestContainerObserver = new ResizeObserver(entries => {
            this.suggestContainerWidth = entries[0].target.clientWidth;
            this._cd.markForCheck();
        });
    }

    private _selectActiveItem(closeAfterSelect: boolean) {
        const item = this.headerItems![this.activeIndex] ?? this.items[this.activeIndex - this.headerItems!.length];
        if (!item) { return; }

        this.updateValue(item, closeAfterSelect);
        this._scrollTo$.next(this.activeIndex);
    }

    private _findItemIndex = (searchValue: string) =>
        () => {
            const headerItemIndex = this.headerItems!.findIndex(({ text }) => caseInsensitiveCompare(text, searchValue));
            const itemIndex = this._items.findIndex(({ text }) => caseInsensitiveCompare(text, searchValue));

            return headerItemIndex !== -1
                ? headerItemIndex
                : itemIndex !== -1
                    ? this.computedItemsOffset(itemIndex)
                    : -1;
        };

    private _safeCycleIncrement(increment: number) {
        let newIndex = this.activeIndex + increment;
        const isOutOfBoundsUpper = newIndex > this._itemUpperBound;

        if (isOutOfBoundsUpper) {
            newIndex = this._itemLowerBound;
        } else {
            const isOutOfBoundsDownward = newIndex < this._itemLowerBound;
            if (isOutOfBoundsDownward) {
                newIndex = this._itemUpperBound;
            }
        }

        this.activeIndex = newIndex;
    }

    private _checkCustomValue(searchValue: string) {
        return (itemIndex: number) => this.enableCustomValue &&
            this._setHasCustomValue(!!searchValue && itemIndex === -1 || !!this.headerItems!.length);
    }

    private _setHasCustomValue(isCustomValue: boolean) {
        if (this._hasCustomValue$.value === isCustomValue) { return; }
        this._hasCustomValue$.next(isCustomValue);
    }

    private _virtualScrollTo = (index: number) => {
        const vs = this._virtualScroller;
        const headerItemsOffset = this._headerItems.length;
        const customValueOffset = this._itemLowerBound;

        if (!vs ||
            (this.isDown
                ? index !== 0
                : index !== this._items.length + this.headerItems!.length - 1) &&
            index >= this._visibleRange.start + customValueOffset &&
            index < this._visibleRange.end + customValueOffset
        ) {
            return;
        }

        const passedBottomBound = index === this._visibleRange.end + customValueOffset;
        const passedTopBound = index < this._visibleRange.start + customValueOffset;

        const start = passedBottomBound
            ? index + 1 - customValueOffset - this.displayCount
            : Math.max(Math.min(index, this.items.length + headerItemsOffset - this.displayCount), 0);

        const end = start + this.displayCount;

        this._visibleRange = {
            start,
            end,
        };

        vs.setRenderedRange({
            start,
            end,
        });
        vs.setRenderedContentOffset(start * this.itemSize);

        if (
            end > this._itemUpperBound
            && (this.isCustomValueVisible || this.isCustomHeaderItemsVisible)
        ) {
            this._gotoBottomAsync(vs.elementRef.nativeElement);
        } else {
            // this is not an error it should go to index
            // which can be outside the safe zone due to customValue
            const targetIndex = this._isOnCustomValueIndex
                ? index
                : start + Number(this.isDown && this.isCustomValueVisible && passedTopBound);

            vs.scrollToIndex(targetIndex);
        }
    };

    private _announceNavigate() {
        if (!this.items.length && !this._isOnCustomValueIndex) {
            this._liveAnnouncer.announce(this.intl.noResultsLabel);
            return;
        }

        const item = this.activeIndex < this.headerItems!.length
            ? this.headerItems![this.activeIndex]
            : this.items[this.activeIndex - this.headerItems!.length];

        const isCurrentItemSelected = !this._isOnCustomValueIndex
            ? this.isItemSelected(item)
            : undefined;

        const textToAnnounce = !this._isOnCustomValueIndex
            ? item.text
            : `${this.intl.customValueLiveLabel} ${this.customValueLabelTranslator(this.inputControl.value)}`;

        this._liveAnnouncer.announce(
            this.intl.currentItemLabel(
                textToAnnounce,
                this.activeIndex + 1,
                this.headerItems!.length + this._items.length,
                isCurrentItemSelected,
            ),
        );
    }

    private _setLoadingState = () => !this.disabled && this.searchSourceFactory && this.loading$.next(true);

    private _focusChanged(isFocused: boolean) {
        if (isFocused === this.focused) { return; }

        this.focused = isFocused;
        this.stateChanges.next();
    }

    private _setInitialItems = (searchResponse: ISuggestValues<any>) => {
        this._items = mapInitialItems(searchResponse,
            this.displayPriority,
            this.value,
            this.intl.loadingLabel,
            this.isDown,
            this._isLazyMode,
        );

        if (this._shouldAddLoadingElementInLazyMode(searchResponse?.data ?? [])) {
            this._addLoadingElementInLazyMode();
        }

        if (!this.sourceInitialized.closed) {
            this.sourceInitialized.emit(this.items);
            this.sourceInitialized.complete();
        }
        this.sourceUpdated.emit(this.items);
    };

    private _setActiveIndex = (itemIndex: number) => {
        this.activeIndex = itemIndex > -1 ?
            itemIndex :
            this.isDown ?
                this._itemLowerBound
                : this._itemUpperBound;
    };

    private _setAndScrollToActiveIndex(value: ISuggestValue) {
        const hasSingleSelectValue = !!value && !this.multiple;
        const headerItemsIndex = hasSingleSelectValue
            ? this.headerItems!.findIndex(({ id }) => id === value.id)
            : -1;

        this._setActiveIndex(
            hasSingleSelectValue
                ? headerItemsIndex !== -1
                    ? headerItemsIndex
                    : this.computedItemsOffset(this._items.findIndex(({ id }) => id === value.id))
                : -1,
        );

        this._scrollTo$.next(this.activeIndex);
    }

    private _scrollToFirst() {
        this._scrollTo$.next(this.isDown ?
            this._itemLowerBound
            : this._itemUpperBound);
    }

    private _deselectValuesFrom(idx: number, deleteCount?: number) {
        const deselected = deleteCount ?
            this.value.splice(idx, deleteCount) :
            this.value.splice(idx);

        deselected.forEach(item => this.deselected.emit(item));
    }

    private _pushEntry(entry: ISuggestValue) {
        const headerItemIndex = this.headerItems!.findIndex(val => val.id === entry.id);
        this._setActiveIndex(
            headerItemIndex !== -1
                ? headerItemIndex
                : this.computedItemsOffset(this._items.indexOf(entry)),
        );

        if (this.multiple) {
            this.value.push(entry);
        } else {
            this.value.splice(0, 1, entry);
        }

        this.selected.emit(entry);
        this.registerChange(this.value);
    }

    private _clearSelection() {
        this._deselectValuesFrom(0);
    }

    private _removeEntry(value: ISuggestValue) {
        const index = this.value.findIndex(({ id }) => id === value.id);
        this._deselectValuesFrom(index, 1);
        this.registerChange(this.value);
    }

    private _rangeLoad = ({ start, end }: ListRange) => {
        if (this.searchSourceFactory == null) {
            throw new Error('searchSourceFactory is not defined');
        }

        const fetchStart = start;
        const fetchCount = this._isLazyMode ? this._fetchCount : end - start + 1;

        const newArguments = [this.inputControl.value.trim(), fetchCount, fetchStart];

        const isRedundantCall = this._isLazyMode
            && (isEqual(this._lazyLoadLastArgument, newArguments)
                || (!this.isDown && end < this._lazyLoadLastArgument?.[2]));

        if (isRedundantCall) {
            return;
        }

        this._lazyLoadLastArgument = newArguments;

        const mappedStart = this.isDown ? start : this._items.length - end - 1;
        const mappedEnd = this.isDown ? end : this._items.length - start - 1;

        if (this._isLazyMode) {
            setPendingState(this._items, 0, this._items.length);
        } else {
            setPendingState(this._items, mappedStart, mappedEnd);
        }

        this.searchSourceFactory(...newArguments)
            .pipe(
                retry(1),
                map(res => this.isDown ? res : {
                    data: (res.data ?? []).reverse(),
                    total: res.total,
                }),
                tap(() => !this._isLazyMode && this._resetIfTotalCountChange),
                takeUntil(
                    merge(
                        this.inputControl.valueChanges.pipe(
                            distinctUntilChanged(),
                        ),
                        this._destroyed$,
                    ),
                ),
                finalize(
                    () => this._isLazyMode ?
                        resetUnloadedState(this._items, 0, this._items.length) :
                        resetUnloadedState(this._items, mappedStart, mappedEnd),
                ),
            )
            .subscribe(({ data = [] }) => {
                if (data.length === 0) {
                    this._removeUnresolvedElements();
                } else {
                    this._items = setLoadedState(
                        data,
                        mappedStart,
                        this._items,
                        this._isLazyMode,
                    );

                    if (this._shouldAddLoadingElementInLazyMode(data)) {
                        this._addLoadingElementInLazyMode();
                    }

                    if (this._shouldLoadMoreOnUpDirection()) {
                        this._loadMore();
                    }
                }

                this.sourceUpdated.emit(this.items);
                this._cd.detectChanges();
            });
    };

    private _resetIfTotalCountChange = ({ total }: ISuggestValues<any>) => {
        const totalCountHasChanged = this._items.length > 0 && total !== this._items.length;
        if (!totalCountHasChanged) {
            return;
        }
        this._items = generateLoadingInitialCollection(this.intl.loadingLabel, total);
        if (total) {
            this._triggerViewportRefresh$.next(null);
        }
    };

    private _gotoBottomAsync(element: HTMLElement) {
        setTimeout(() => element.scrollTop = element.scrollHeight - element.clientHeight, 0);
    }

    private _focusChipInput() {
        // direct focus needed as chip component doesn't expose a focus to input mechanism
        (this._elementRef.nativeElement as HTMLElement).querySelector<HTMLInputElement>(MAT_CHIP_INPUT_SELECTOR)?.focus();
    }

    private _checkUnsuportedScenarios() {
        const UNSUPPORTED_SCENARIOS = [
            {
                errorText: 'enableCustomValue and headerItems are mutually exclusive options',
                scenario: !!this.headerItems!.length && this.enableCustomValue,
            },
            {
                errorText: 'enableCustomValue should not be used with minChars',
                scenario: this.enableCustomValue && this.minChars > 0,
            },
            {
                errorText: 'direction up is not supported when used in conjunction with headerItems',
                scenario: !!this.headerItems!.length && this.direction === 'up',
            },
        ];

        UNSUPPORTED_SCENARIOS.forEach(({ errorText, scenario }) => {
            if (scenario) { throw new Error(errorText); }
        });
    }

    private _getValueSummary(fromTooltip = false) {
        return (this.displayValueFactory ?? this._defaultDisplayValueFactory)(this.value, fromTooltip);
    }

    private _defaultDisplayValueFactory = (value?: ISuggestValue[], fromTooltip = false) =>
        (value ?? []).map(v => this.intl.translateLabel((fromTooltip && v.tooltip) || v.text)).join(', ');

    private _cantNavigate(increment: number) {
        return (!this.items.length &&
            !this.enableCustomValue) ||
            (this._isLazyMode &&
                this._items[this.activeIndex] &&
                this._items[this.activeIndex]?.loading !== VirtualScrollItemStatus.loaded &&
                increment > 0
            );
    }

    private get _isLazyMode() {
        return this.searchSourceStrategy === 'lazy';
    }

    private _removeUnresolvedElements() {
        this._items = this._items
            .filter(({ loading }) => loading === VirtualScrollItemStatus.loaded);

    }

    private _shouldAddLoadingElementInLazyMode(currentResponse: ISuggestValue[]) {
                return this._isLazyMode && currentResponse.length >= this.displayCount;
    }

    private _addLoadingElementInLazyMode() {
        const totalLoadingElements = Math.floor(this.displayCount / 2);
        const loadingElements = generateLoadingInitialCollection(this.intl.loadingLabel, totalLoadingElements);

        if (this.isDown) {
            this._items.push(...loadingElements);
        } else {
            this._items.unshift(...loadingElements);
        }

    }

    private _shouldLoadMoreOnUpDirection() {
        const isUp = !this.isDown;
        const areMoreItemsThankVSKnows = this._virtualScroller?.getDataLength() !== this._items.length;
        const isVSAtTop = (this._virtualScroller?.measureScrollOffset() ?? 0) < this.itemSize * 10;

        return this._isLazyMode && isUp && areMoreItemsThankVSKnows && isVSAtTop;
    }

    private _loadMore() {
        this.rangeLoad({
            start: this._items.length - 1,
            end: this._items.length - 1,
        });
    }

    private _announceSelectStatus(text: string, status: boolean) {
        if (text) {
            this._liveAnnouncer.announce(this.intl.currentItemSelectionStatus(text, status));
        }
    }

    private _isCheckbox(elem?: EventTarget | null) {
        return elem instanceof HTMLInputElement && elem.type === 'checkbox';
    }
}