/* tslint:disable:max-file-line-count */ import { ChangeDetectorRef, Directive, ElementRef, EventEmitter, HostListener, Input, OnDestroy, OnInit, Output, Renderer2, TemplateRef, ViewContainerRef } from '@angular/core'; import { NgControl } from '@angular/forms'; import 'rxjs/add/observable/from'; import 'rxjs/add/operator/debounceTime'; import 'rxjs/add/operator/filter'; import 'rxjs/add/operator/map'; import 'rxjs/add/operator/switchMap'; import 'rxjs/add/operator/mergeMap'; import 'rxjs/add/operator/toArray'; import { Observable } from 'rxjs/Observable'; import { Subscription } from 'rxjs/Subscription'; import { ComponentLoader, ComponentLoaderFactory } from '../component-loader/index'; import { TypeaheadContainerComponent } from './typeahead-container.component'; import { TypeaheadMatch } from './typeahead-match.class'; import { getValueFromObject, latinize, tokenize } from './typeahead-utils'; @Directive({selector: '[typeahead]', exportAs: 'bs-typeahead'}) export class TypeaheadDirective implements OnInit, OnDestroy { /** options source, can be Array of strings, objects or * an Observable for external matching process */ @Input() typeahead: any; /** minimal no of characters that needs to be entered before * typeahead kicks-in. When set to 0, typeahead shows on focus with full * list of options (limited as normal by typeaheadOptionsLimit) */ @Input() typeaheadMinLength: number = void 0; /** minimal wait time after last character typed before typeahead kicks-in */ @Input() typeaheadWaitMs: number; /** maximum length of options items list */ @Input() typeaheadOptionsLimit: number; /** when options source is an array of objects, the name of field * that contains the options value, we use array item as option in case * of this field is missing. Supports nested properties and methods. */ @Input() typeaheadOptionField: string; /** when options source is an array of objects, the name of field that * contains the group value, matches are grouped by this field when set. */ @Input() typeaheadGroupField: string; /** should be used only in case of typeahead attribute is array. * If true - loading of options will be async, otherwise - sync. * true make sense if options array is large. */ @Input() typeaheadAsync: boolean = void 0; /** match latin symbols. * If true the word súper would match super and vice versa. */ @Input() typeaheadLatinize = true; /** Can be use to search words by inserting a single white space between each characters * for example 'C a l i f o r n i a' will match 'California'. */ @Input() typeaheadSingleWords = true; /** should be used only in case typeaheadSingleWords attribute is true. * Sets the word delimiter to break words. Defaults to space. */ @Input() typeaheadWordDelimiters = ' '; /** should be used only in case typeaheadSingleWords attribute is true. * Sets the word delimiter to match exact phrase. * Defaults to simple and double quotes. */ @Input() typeaheadPhraseDelimiters = '\'"'; /** used to specify a custom item template. * Template variables exposed are called item and index; */ @Input() typeaheadItemTemplate: TemplateRef; /** used to specify a custom options list template. * Template variables: matches, itemTemplate, query */ @Input() optionsListTemplate: TemplateRef; /** specifies if typeahead is scrollable */ @Input() typeaheadScrollable = false; /** specifies number of options to show in scroll view */ @Input() typeaheadOptionsInScrollableView = 5; /** fired when 'busy' state of this component was changed, * fired on async mode only, returns boolean */ @Output() typeaheadLoading = new EventEmitter(); /** fired on every key event and returns true * in case of matches are not detected */ @Output() typeaheadNoResults = new EventEmitter(); /** fired when option was selected, return object with data of this option */ @Output() typeaheadOnSelect = new EventEmitter(); /** fired when blur event occurres. returns the active item */ @Output() typeaheadOnBlur = new EventEmitter(); /** * A selector specifying the element the typeahead should be appended to. * Currently only supports "body". */ @Input() container: string; /** This attribute indicates that the dropdown should be opened upwards */ @Input() dropup = false; // not yet implemented /** if false restrict model values to the ones selected from the popup only will be provided */ // @Input() protected typeaheadEditable:boolean; /** if false the first match automatically will not be focused as you type */ // @Input() protected typeaheadFocusFirst:boolean; /** format the ng-model result after selection */ // @Input() protected typeaheadInputFormatter:any; /** if true automatically select an item when there is one option that exactly matches the user input */ // @Input() protected typeaheadSelectOnExact:boolean; /** if true select the currently highlighted match on blur */ // @Input() protected typeaheadSelectOnBlur:boolean; /** if false don't focus the input element the typeahead directive is associated with on selection */ // @Input() protected typeaheadFocusOnSelect:boolean; _container: TypeaheadContainerComponent; isTypeaheadOptionsListActive = false; protected keyUpEventEmitter: EventEmitter = new EventEmitter(); protected _matches: TypeaheadMatch[]; protected placement = 'bottom-left'; // protected popup:ComponentRef; private _typeahead: ComponentLoader; private _subscriptions: Subscription[] = []; private _outsideClickListener: Function; constructor(private ngControl: NgControl, private element: ElementRef, viewContainerRef: ViewContainerRef, private renderer: Renderer2, cis: ComponentLoaderFactory, private changeDetection: ChangeDetectorRef) { this._typeahead = cis.createLoader( element, viewContainerRef, renderer ); } ngOnInit(): void { this.typeaheadOptionsLimit = this.typeaheadOptionsLimit || 20; this.typeaheadMinLength = this.typeaheadMinLength === void 0 ? 1 : this.typeaheadMinLength; this.typeaheadWaitMs = this.typeaheadWaitMs || 0; // async should be false in case of array if ( this.typeaheadAsync === undefined && !(this.typeahead instanceof Observable) ) { this.typeaheadAsync = false; } if (this.typeahead instanceof Observable) { this.typeaheadAsync = true; } if (this.typeaheadAsync) { this.asyncActions(); } else { this.syncActions(); } } @HostListener('input', ['$event']) onInput(e: any): void { // For ``s, use the `value` property. For others that don't have a // `value` (such as ``), use either // `textContent` or `innerText` (depending on which one is supported, i.e. // Firefox or IE). const value = e.target.value !== undefined ? e.target.value : e.target.textContent !== undefined ? e.target.textContent : e.target.innerText; if (value != null && value.trim().length >= this.typeaheadMinLength) { this.typeaheadLoading.emit(true); this.keyUpEventEmitter.emit(e.target.value); } else { this.typeaheadLoading.emit(false); this.typeaheadNoResults.emit(false); this.hide(); } } @HostListener('keyup', ['$event']) onChange(e: any): void { if (this._container) { // esc if (e.keyCode === 27) { this.hide(); return; } // up if (e.keyCode === 38) { this._container.prevActiveMatch(); return; } // down if (e.keyCode === 40) { this._container.nextActiveMatch(); return; } // enter, tab if (e.keyCode === 13) { this._container.selectActiveMatch(); return; } } } @HostListener('click') @HostListener('focus') onFocus(): void { if (this.typeaheadMinLength === 0) { this.typeaheadLoading.emit(true); this.keyUpEventEmitter.emit(this.element.nativeElement.value || ''); } } @HostListener('blur') onBlur(): void { if (this._container && !this._container.isFocused) { this.typeaheadOnBlur.emit(this._container.active); } } @HostListener('keydown', ['$event']) onKeydown(e: any): void { // no container - no problems if (!this._container) { return; } // if an item is visible - prevent form submission if (e.keyCode === 13) { e.preventDefault(); return; } // if an item is visible - don't change focus if (e.keyCode === 9) { e.preventDefault(); this._container.selectActiveMatch(); return; } } changeModel(match: TypeaheadMatch): void { const valueStr: string = match.value; this.ngControl.viewToModelUpdate(valueStr); (this.ngControl.control).setValue(valueStr); this.changeDetection.markForCheck(); this.hide(); } get matches(): any[] { return this._matches; } show(): void { this._typeahead .attach(TypeaheadContainerComponent) // todo: add append to body, after updating positioning service .to(this.container) .position({attachment: `${this.dropup ? 'top' : 'bottom'} left`}) .show({ typeaheadRef: this, placement: this.placement, animation: false, dropup: this.dropup }); this._outsideClickListener = this.renderer.listen('document', 'click', (e: MouseEvent) => { if (this.typeaheadMinLength === 0 && this.element.nativeElement.contains(e.target)) { return; } this.onOutsideClick(); }); this._container = this._typeahead.instance; this._container.parent = this; // This improves the speed as it won't have to be done for each list item const normalizedQuery = (this.typeaheadLatinize ? latinize(this.ngControl.control.value) : this.ngControl.control.value) .toString() .toLowerCase(); this._container.query = this.typeaheadSingleWords ? tokenize( normalizedQuery, this.typeaheadWordDelimiters, this.typeaheadPhraseDelimiters ) : normalizedQuery; this._container.matches = this._matches; this.element.nativeElement.focus(); } hide(): void { if (this._typeahead.isShown) { this._typeahead.hide(); this._outsideClickListener(); this._container = null; } } onOutsideClick(): void { if (this._container && !this._container.isFocused) { this.hide(); } } ngOnDestroy(): any { // clean up subscriptions for (const sub of this._subscriptions) { sub.unsubscribe(); } this._typeahead.dispose(); } protected asyncActions(): void { this._subscriptions.push( this.keyUpEventEmitter .debounceTime(this.typeaheadWaitMs) .switchMap(() => this.typeahead) .subscribe((matches: any[]) => { this.finalizeAsyncCall(matches); }) ); } protected syncActions(): void { this._subscriptions.push( this.keyUpEventEmitter .debounceTime(this.typeaheadWaitMs) .mergeMap((value: string) => { const normalizedQuery = this.normalizeQuery(value); return Observable.from(this.typeahead) .filter((option: any) => { return ( option && this.testMatch(this.normalizeOption(option), normalizedQuery) ); }) .toArray(); }) .subscribe((matches: any[]) => { this.finalizeAsyncCall(matches); }) ); } protected normalizeOption(option: any): any { const optionValue: string = getValueFromObject( option, this.typeaheadOptionField ); const normalizedOption = this.typeaheadLatinize ? latinize(optionValue) : optionValue; return normalizedOption.toLowerCase(); } protected normalizeQuery(value: string): any { // If singleWords, break model here to not be doing extra work on each // iteration let normalizedQuery: any = (this.typeaheadLatinize ? latinize(value) : value) .toString() .toLowerCase(); normalizedQuery = this.typeaheadSingleWords ? tokenize( normalizedQuery, this.typeaheadWordDelimiters, this.typeaheadPhraseDelimiters ) : normalizedQuery; return normalizedQuery; } protected testMatch(match: string, test: any): boolean { let spaceLength: number; if (typeof test === 'object') { spaceLength = test.length; for (let i = 0; i < spaceLength; i += 1) { if (test[i].length > 0 && match.indexOf(test[i]) < 0) { return false; } } return true; } return match.indexOf(test) >= 0; } protected finalizeAsyncCall(matches: any[]): void { this.prepareMatches(matches); this.typeaheadLoading.emit(false); this.typeaheadNoResults.emit(!this.hasMatches()); if (!this.hasMatches()) { this.hide(); return; } if (this._container) { // This improves the speed as it won't have to be done for each list item const normalizedQuery = (this.typeaheadLatinize ? latinize(this.ngControl.control.value) : this.ngControl.control.value) .toString() .toLowerCase(); this._container.query = this.typeaheadSingleWords ? tokenize( normalizedQuery, this.typeaheadWordDelimiters, this.typeaheadPhraseDelimiters ) : normalizedQuery; this._container.matches = this._matches; } else { this.show(); } } protected prepareMatches(options: any[]): void { const limited: any[] = options.slice(0, this.typeaheadOptionsLimit); if (this.typeaheadGroupField) { let matches: TypeaheadMatch[] = []; // extract all group names const groups = limited .map((option: any) => getValueFromObject(option, this.typeaheadGroupField) ) .filter((v: string, i: number, a: any[]) => a.indexOf(v) === i); groups.forEach((group: string) => { // add group header to array of matches matches.push(new TypeaheadMatch(group, group, true)); // add each item of group to array of matches matches = matches.concat( limited .filter( (option: any) => getValueFromObject(option, this.typeaheadGroupField) === group ) .map( (option: any) => new TypeaheadMatch( option, getValueFromObject(option, this.typeaheadOptionField) ) ) ); }); this._matches = matches; } else { this._matches = limited.map( (option: any) => new TypeaheadMatch( option, getValueFromObject(option, this.typeaheadOptionField) ) ); } } protected hasMatches(): boolean { return this._matches.length > 0; } }