import { ChangeDetectorRef, Directive, ElementRef, EventEmitter, HostBinding, HostListener, Inject, Input, OnDestroy, Optional, Output, Self, AfterViewInit, OnInit } from '@angular/core'; import { NgModel, FormControlName } from '@angular/forms'; import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; import { CancelableEventArgs, IBaseEventArgs } from '../../core/utils'; import { AbsoluteScrollStrategy, AutoPositionStrategy, IPositionStrategy, IScrollStrategy, OverlaySettings } from '../../services/public_api'; import { IgxDropDownComponent } from '../../drop-down/drop-down.component'; import { IgxDropDownItemNavigationDirective } from '../../drop-down/drop-down-navigation.directive'; import { IgxInputGroupComponent } from '../../input-group/public_api'; import { IgxOverlayOutletDirective } from '../toggle/toggle.directive'; import { ISelectionEventArgs } from '../../drop-down/drop-down.common'; /** * Interface that encapsulates onItemSelection event arguments - new value and cancel selection. * * @export */ export interface AutocompleteSelectionChangingEventArgs extends CancelableEventArgs, IBaseEventArgs { /** * New value selected from the drop down */ value: string; } export interface AutocompleteOverlaySettings { /** Position strategy to use with this settings */ positionStrategy?: IPositionStrategy; /** Scroll strategy to use with this settings */ scrollStrategy?: IScrollStrategy; /** Set the outlet container to attach the overlay to */ outlet?: IgxOverlayOutletDirective | ElementRef; } /** * **Ignite UI for Angular Autocomplete** - * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/autocomplete.html) * * The igxAutocomplete directive provides a way to enhance a text input * by showing a drop down of suggested options, provided by the developer. * * Example: * ```html * * * * {{town}} * * * ``` */ @Directive({ selector: '[igxAutocomplete]', exportAs: 'igxAutocomplete', standalone: true }) export class IgxAutocompleteDirective extends IgxDropDownItemNavigationDirective implements OnDestroy, AfterViewInit, OnInit { /** * Sets the target of the autocomplete directive * * ```html * * * ... * * ... * * ``` */ @Input('igxAutocomplete') public override get target(): IgxDropDownComponent { return this._target as IgxDropDownComponent; } public override set target(v: IgxDropDownComponent) { this._target = v; } /** * Provide overlay settings for the autocomplete drop down * * ```typescript * // get * let settings = this.autocomplete.autocompleteSettings; * ``` * ```html * * * ``` * ```typescript * // set * this.settings = { * positionStrategy: new ConnectedPositioningStrategy({ * closeAnimation: null, * openAnimation: null * }) * }; * ``` */ @Input('igxAutocompleteSettings') public autocompleteSettings: AutocompleteOverlaySettings; /** @hidden @internal */ @HostBinding('attr.autocomplete') public autofill = 'off'; /** @hidden @internal */ @HostBinding('attr.role') public role = 'combobox'; /** * Enables/disables autocomplete component * * ```typescript * // get * let disabled = this.autocomplete.disabled; * ``` * ```html * * * ``` * ```typescript * // set * public disabled = true; * ``` */ @Input('igxAutocompleteDisabled') public disabled = false; /** * Emitted after item from the drop down is selected * * ```html * * ``` */ @Output() public selectionChanging = new EventEmitter(); /** @hidden @internal */ public get nativeElement(): HTMLInputElement { return this.elementRef.nativeElement; } /** @hidden @internal */ public get parentElement(): HTMLElement { return this.group ? this.group.element.nativeElement : this.nativeElement; } private get settings(): OverlaySettings { const settings = Object.assign({}, this.defaultSettings, this.autocompleteSettings); const target = settings.target || settings.positionStrategy.settings.target; if (!target) { const positionStrategyClone: IPositionStrategy = settings.positionStrategy.clone(); settings.target = this.parentElement; settings.positionStrategy = positionStrategyClone; } return settings; } /** @hidden @internal */ @HostBinding('attr.aria-expanded') public get ariaExpanded() { return !this.collapsed; } /** @hidden @internal */ @HostBinding('attr.aria-haspopup') public get hasPopUp() { return 'listbox'; } /** @hidden @internal */ @HostBinding('attr.aria-owns') public get ariaOwns() { return this.target.listId; } /** @hidden @internal */ @HostBinding('attr.aria-activedescendant') public get ariaActiveDescendant() { return !this.target.collapsed && this.target.focusedItem ? this.target.focusedItem.id : null; } /** @hidden @internal */ @HostBinding('attr.aria-autocomplete') public get ariaAutocomplete() { return 'list'; } protected _composing: boolean; protected id: string; protected get model() { return this.ngModel || this.formControl; } private _shouldBeOpen = false; private destroy$ = new Subject(); private defaultSettings: OverlaySettings; constructor(@Self() @Optional() @Inject(NgModel) protected ngModel: NgModel, @Self() @Optional() @Inject(FormControlName) protected formControl: FormControlName, @Optional() protected group: IgxInputGroupComponent, protected elementRef: ElementRef, protected cdr: ChangeDetectorRef) { super(null); } /** @hidden @internal */ @HostListener('input') public onInput() { this.open(); } /** @hidden @internal */ @HostListener('compositionstart') public onCompositionStart(): void { if (!this._composing) { this._composing = true; } } /** @hidden @internal */ @HostListener('compositionend') public onCompositionEnd(): void { this._composing = false; } /** @hidden @internal */ @HostListener('keydown.ArrowDown', ['$event']) @HostListener('keydown.Alt.ArrowDown', ['$event']) @HostListener('keydown.ArrowUp', ['$event']) @HostListener('keydown.Alt.ArrowUp', ['$event']) public onArrowDown(event: Event) { event.preventDefault(); this.open(); } /** @hidden @internal */ @HostListener('keydown.Tab') @HostListener('keydown.Shift.Tab') public onTab() { this.close(); } /** @hidden @internal */ public override handleKeyDown(event) { if (!this.collapsed && !this._composing) { switch (event.key.toLowerCase()) { case 'space': case 'spacebar': case ' ': case 'home': case 'end': return; default: super.handleKeyDown(event); } } } /** @hidden @internal */ public override onArrowDownKeyDown() { super.onArrowDownKeyDown(); } /** @hidden @internal */ public override onArrowUpKeyDown() { super.onArrowUpKeyDown(); } /** @hidden @internal */ public override onEndKeyDown() { super.onEndKeyDown(); } /** @hidden @internal */ public override onHomeKeyDown() { super.onHomeKeyDown(); } /** * Closes autocomplete drop down */ public close() { this._shouldBeOpen = false; if (this.collapsed) { return; } this.target.close(); } /** * Opens autocomplete drop down */ public open() { this._shouldBeOpen = true; if (this.disabled || !this.collapsed || this.target.children.length === 0) { return; } // if no drop-down width is set, the drop-down will be as wide as the autocomplete input; this.target.width = this.target.width || (this.parentElement.clientWidth + 'px'); this.target.open(this.settings); this.highlightFirstItem(); } /** @hidden @internal */ public ngOnInit() { const targetElement = this.parentElement; this.defaultSettings = { target: targetElement, modal: false, scrollStrategy: new AbsoluteScrollStrategy(), positionStrategy: new AutoPositionStrategy(), excludeFromOutsideClick: [targetElement] }; } /** @hidden */ public ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); } public ngAfterViewInit() { this.target.children.changes.pipe(takeUntil(this.destroy$)).subscribe(() => { if (this.target.children.length) { if (!this.collapsed) { this.highlightFirstItem(); } else if (this._shouldBeOpen) { this.open(); } } else { // _shouldBeOpen flag should remain unchanged since this state change doesn't come from outside of the component // (like in the case of public API or user interaction). this.target.close(); } }); this.target.selectionChanging.pipe(takeUntil(this.destroy$)).subscribe(this.select.bind(this)); } private get collapsed(): boolean { return this.target ? this.target.collapsed : true; } private select(value: ISelectionEventArgs) { if (!value.newSelection) { return; } value.cancel = true; // Disable selection in the drop down, because in autocomplete we do not save selection. const newValue = value.newSelection.value; const args: AutocompleteSelectionChangingEventArgs = { value: newValue, cancel: false }; this.selectionChanging.emit(args); if (args.cancel) { return; } this.close(); // Update model after the input is re-focused, in order to have proper valid styling. // Otherwise when item is selected using mouse (and input is blurred), then valid style will be removed. if (this.model) { this.model.control.setValue(newValue); } else { this.nativeElement.value = newValue; } } private highlightFirstItem() { if (this.target.focusedItem) { this.target.focusedItem.focused = false; this.target.focusedItem = null; } this.target.navigateFirst(); this.cdr.detectChanges(); } }