From 48b8abb126b648ec76aae9fbc13365ebc1a469b5 Mon Sep 17 00:00:00 2001 From: Christian Ulmann Date: Thu, 23 Jun 2016 15:18:26 +0200 Subject: [PATCH] feat(typeahead): rxjs version (#584) * Updated: rxjs for typeahead component * feat(typeahead): rxjs * fix(typeahead): Added timeout around onSelect emit (fix #536) fixes #637 --- components/typeahead/readme.md | 2 +- .../typeahead-container.component.ts | 8 +- components/typeahead/typeahead.directive.ts | 245 +++++++----------- demo/components/typeahead/typeahead-demo.html | 6 +- demo/components/typeahead/typeahead-demo.ts | 37 +-- 5 files changed, 115 insertions(+), 183 deletions(-) diff --git a/components/typeahead/readme.md b/components/typeahead/readme.md index c3f3b34b71..0028223bd2 100644 --- a/components/typeahead/readme.md +++ b/components/typeahead/readme.md @@ -9,7 +9,7 @@ import { TYPEAHEAD_DIRECTIVES } from 'ng2-bootstrap/components/typeahead'; ```typescript // directive Typeahead @Directive({ - selector: 'typeahead, [typeahead]' + selector: '[typeahead][ngModel]' }) export class TypeaheadDirective implements OnInit { @Output() public typeaheadLoading:EventEmitter; diff --git a/components/typeahead/typeahead-container.component.ts b/components/typeahead/typeahead-container.component.ts index 9bcce79598..b10c47d3bf 100644 --- a/components/typeahead/typeahead-container.component.ts +++ b/components/typeahead/typeahead-container.component.ts @@ -153,9 +153,11 @@ export class TypeaheadContainerComponent { e.preventDefault(); } this.parent.changeModel(value); - this.parent.typeaheadOnSelect.emit({ - item: value - }); + setTimeout(() => + this.parent.typeaheadOnSelect.emit({ + item: value + }), 0 + ); return false; } } diff --git a/components/typeahead/typeahead.directive.ts b/components/typeahead/typeahead.directive.ts index 4165bcad41..808bbad188 100644 --- a/components/typeahead/typeahead.directive.ts +++ b/components/typeahead/typeahead.directive.ts @@ -7,6 +7,14 @@ import {TypeaheadUtils} from './typeahead-utils'; import {TypeaheadContainerComponent} from './typeahead-container.component'; import {TypeaheadOptions} from './typeahead-options.class'; +import 'rxjs/add/observable/from'; +import 'rxjs/add/operator/debounceTime'; +import 'rxjs/add/operator/filter'; +import 'rxjs/add/operator/map'; +import 'rxjs/add/operator/mergeMap'; +import 'rxjs/add/operator/toArray';; +import {Observable} from 'rxjs/Observable'; + import {global} from '@angular/core/src/facade/lang'; /* tslint:disable */ const KeyboardEvent = (global as any).KeyboardEvent as KeyboardEvent; @@ -48,8 +56,8 @@ export class TypeaheadDirective implements OnInit { public container:TypeaheadContainerComponent; public isTypeaheadOptionsListActive:boolean = false; - private debouncer:Function; - private _matches:Array = []; + private keyUpEventEmitter:EventEmitter = new EventEmitter(); + private _matches:Array; private placement:string = 'bottom-left'; private popup:Promise>; @@ -60,7 +68,7 @@ export class TypeaheadDirective implements OnInit { private loader:DynamicComponentLoader; @HostListener('keyup', ['$event']) - protected onChange(e:KeyboardEvent):void { + protected onChange(e:any):void { if (this.container) { // esc if (e.keyCode === 27) { @@ -87,22 +95,12 @@ export class TypeaheadDirective implements OnInit { } } - // Ensure that we have typed enough characters before triggering the - // matchers - if (this.cd.model.toString().length >= this.typeaheadMinLength) { - + if(e.target.value.trim().length >= this.typeaheadMinLength) { this.typeaheadLoading.emit(true); - - if (this.typeaheadAsync === true) { - this.debouncer(); - } - - if (!this.typeaheadAsync) { - this.processMatches(); - this.finalizeAsyncCall(); - } + this.keyUpEventEmitter.emit(e.target.value); } else { - // Not enough characters typed? Hide the popup. + this.typeaheadLoading.emit(false); + this.typeaheadNoResults.emit(false); this.hide(); } } @@ -110,20 +108,13 @@ export class TypeaheadDirective implements OnInit { @HostListener('focus', ['$event.target']) protected onFocus():void { if (this.typeaheadMinLength === 0) { + console.log('focus'); this.typeaheadLoading.emit(true); - - if (this.typeaheadAsync === true) { - this.debouncer(); - } - - if (!this.typeaheadAsync) { - this.processMatches(); - this.finalizeAsyncCall(); - } + this.keyUpEventEmitter.emit(''); } } - @HostListener('blur', ['$event.target']) + @HostListener('blur') protected onBlur():void { if (this.container && !this.container.isFocused) { this.hide(); @@ -171,42 +162,34 @@ export class TypeaheadDirective implements OnInit { this.typeaheadWaitMs = this.typeaheadWaitMs || 0; // async should be false in case of array - if (this.typeaheadAsync === void 0 && typeof this.typeahead !== 'function') { + if (this.typeaheadAsync === undefined && !(this.typeahead instanceof Observable)) { this.typeaheadAsync = false; } - // async should be true for any case of function - if (typeof this.typeahead === 'function') { + if (this.typeahead instanceof Observable) { this.typeaheadAsync = true; } - if (this.typeaheadAsync === true) { - this.debouncer = this.debounce(() => { - if (typeof this.typeahead === 'function') { - this.typeahead() - .then((matches:any[]) => { - this._matches = []; - - for (let i = 0; i < matches.length; i++) { - this._matches.push(matches[i]); - if (this._matches.length > this.typeaheadOptionsLimit - 1) { - break; - } - } - - this.finalizeAsyncCall(); - }); - } - - // source is array - if (typeof this.typeahead === 'object' && this.typeahead.length) { - this.processMatches(); - this.finalizeAsyncCall(); - } - }, 100); + if (this.typeaheadAsync) { + this.asyncActions(); + } else { + this.syncActions(); } } + public changeModel(value:any):void { + let valueStr:string = ((typeof value === 'object' && this.typeaheadOptionField) + ? value[this.typeaheadOptionField] + : value).toString(); + this.cd.viewToModelUpdate(valueStr); + setProperty(this.renderer, this.element, 'value', valueStr); + this.hide(); + } + + public get matches():Array { + return this._matches; + } + public show(matches:Array):void { let options = new TypeaheadOptions({ typeaheadRef: this, @@ -249,112 +232,74 @@ export class TypeaheadDirective implements OnInit { } } - public changeModel(value:any):void { - let valueStr:string = ((typeof value === 'object' && this.typeaheadOptionField) - ? value[this.typeaheadOptionField] - : value).toString(); - this.cd.viewToModelUpdate(valueStr); - setProperty(this.renderer, this.element, 'value', valueStr); - this.hide(); - } - - public get matches():Array { - return this._matches; + private asyncActions():void { + this.keyUpEventEmitter + .debounceTime(this.typeaheadWaitMs) + .mergeMap(() => this.typeahead) + .subscribe( + (matches:string[]) => { + this._matches = matches.slice(0, this.typeaheadOptionsLimit); + this.finalizeAsyncCall(); + }, + (err:any) => { + console.error(err); + } + ); } - private debounce(func:Function, wait:number):Function { - let timeout:any; - let args:Array; - let timestamp:number; - let waitOriginal:number = wait; - - return function ():void { - // save details of latest call - args = [].slice.call(arguments, 0); - timestamp = Date.now(); - - // this trick is about implementing of 'typeaheadWaitMs' - // in this case we have adaptive 'wait' parameter - // we should use standard 'wait'('waitOriginal') in case of - // popup is opened, otherwise - 'typeaheadWaitMs' parameter - wait = this.container ? waitOriginal : this.typeaheadWaitMs; - - // this is where the magic happens - let later = function ():void { - - // how long ago was the last call - let last = Date.now() - timestamp; - - // if the latest call was less that the wait period ago - // then we reset the timeout to wait for the difference - if (last < wait) { - timeout = setTimeout(later, wait - last); - // or if not we can null out the timer and run the latest - } else { - timeout = void 0; - func.apply(this, args); + private syncActions():void { + this.keyUpEventEmitter + .debounceTime(this.typeaheadWaitMs) + .mergeMap((value:string) => { + let normalizedQuery = this.normalizeQuery(value); + + return Observable.from(this.typeahead) + .filter((option:any) => { + return option && this.testMatch(this.prepareOption(option).toLowerCase(), normalizedQuery); + }) + .toArray(); + }) + .subscribe( + (matches:string[]) => { + this._matches = matches.slice(0, this.typeaheadOptionsLimit); + this.finalizeAsyncCall(); + }, + (err:any) => { + console.error(err); } - }; - - // we only need to set the timer now if one isn't already running - if (!timeout) { - timeout = setTimeout(later, wait); - } - }; + ); } - private processMatches():void { - this._matches = []; + private prepareOption(option:any):any { + let match:any; - if (!this.typeahead) { - return; + if (typeof option === 'object' && + option[this.typeaheadOptionField]) { + match = this.typeaheadLatinize ? + TypeaheadUtils.latinize(option[this.typeaheadOptionField].toString()) : + option[this.typeaheadOptionField].toString(); } - if (!this.cd.model) { - for (let i = 0; i < Math.min(this.typeaheadOptionsLimit, this.typeahead.length); i++) { - this._matches.push(this.typeahead[i]); - } - return; + if (typeof option === 'string') { + match = this.typeaheadLatinize ? + TypeaheadUtils.latinize(option.toString()) : + option.toString(); } - // If singleWords, break model here to not be doing extra work on each - // iteration - let normalizedQuery = (this.typeaheadLatinize - ? TypeaheadUtils.latinize(this.cd.model) - : this.cd.model).toString() - .toLowerCase(); - normalizedQuery = this.typeaheadSingleWords - ? TypeaheadUtils.tokenize(normalizedQuery, this.typeaheadWordDelimiters, this.typeaheadPhraseDelimiters) - : normalizedQuery; - for (let i = 0; i < this.typeahead.length; i++) { - let match:string; - - if (typeof this.typeahead[i] === 'object' && - this.typeahead[i][this.typeaheadOptionField]) { - match = this.typeaheadLatinize - ? TypeaheadUtils.latinize(this.typeahead[i][this.typeaheadOptionField].toString()) - : this.typeahead[i][this.typeaheadOptionField].toString(); - } - - if (typeof this.typeahead[i] === 'string') { - match = this.typeaheadLatinize - ? TypeaheadUtils.latinize(this.typeahead[i].toString()) - : this.typeahead[i].toString(); - } - - if (!match) { - console.log('Invalid match type', typeof this.typeahead[i], this.typeaheadOptionField); - continue; - } + return match; + } - if (this.testMatch(match.toLowerCase(), normalizedQuery)) { - this._matches.push(this.typeahead[i]); - if (this._matches.length > this.typeaheadOptionsLimit - 1) { - break; - } - } - } + private normalizeQuery(value:string):any { + // If singleWords, break model here to not be doing extra work on each iteration + let normalizedQuery:any = + (this.typeaheadLatinize ? TypeaheadUtils.latinize(value) : value) + .toString() + .toLowerCase(); + normalizedQuery = this.typeaheadSingleWords ? + TypeaheadUtils.tokenize(normalizedQuery, this.typeaheadWordDelimiters, this.typeaheadPhraseDelimiters) : + normalizedQuery; + return normalizedQuery; } private testMatch(match:string, test:any):boolean { @@ -375,8 +320,7 @@ export class TypeaheadDirective implements OnInit { private finalizeAsyncCall():void { this.typeaheadLoading.emit(false); - this.typeaheadNoResults.emit(this.cd.model.toString().length >= - this.typeaheadMinLength && this.matches.length <= 0); + this.typeaheadNoResults.emit(this.matches.length <= 0); if (this._matches.length <= 0) { this.hide(); @@ -399,4 +343,5 @@ export class TypeaheadDirective implements OnInit { this.show(this._matches); } } + } diff --git a/demo/components/typeahead/typeahead-demo.html b/demo/components/typeahead/typeahead-demo.html index b24efbaae8..80f726bb11 100644 --- a/demo/components/typeahead/typeahead-demo.html +++ b/demo/components/typeahead/typeahead-demo.html @@ -2,19 +2,19 @@

Static arrays

Model: {{selected | json}}

Asynchronous results

Model: {{asyncSelected | json}}
diff --git a/demo/components/typeahead/typeahead-demo.ts b/demo/components/typeahead/typeahead-demo.ts index 03ef0ea680..4bacc561ad 100644 --- a/demo/components/typeahead/typeahead-demo.ts +++ b/demo/components/typeahead/typeahead-demo.ts @@ -1,5 +1,7 @@ import {Component} from '@angular/core'; import {CORE_DIRECTIVES, FORM_DIRECTIVES} from '@angular/common'; +import {Observable} from 'rxjs/Observable'; + import {TYPEAHEAD_DIRECTIVES} from '../../../ng2-bootstrap'; // webpack html imports @@ -12,6 +14,7 @@ let template = require('./typeahead-demo.html'); }) export class TypeaheadDemoComponent { public selected:string = ''; + public dataSource:Observable; public asyncSelected:string = ''; public typeaheadLoading:boolean = false; public typeaheadNoResults:boolean = false; @@ -55,32 +58,14 @@ export class TypeaheadDemoComponent { {id: 49, name: 'West Virginia'}, {id: 50, name: 'Wisconsin'}, {id: 51, name: 'Wyoming'}]; - private _cache:any; - private _prevContext:any; - - public getContext():any { - return this; - } - - public getAsyncData(context:any):Function { - if (this._prevContext === context) { - return this._cache; - } + public constructor() { + this.dataSource = Observable.create((observer:any) => { + let query = new RegExp(this.asyncSelected, 'ig'); - this._prevContext = context; - let f:Function = function ():Promise { - let p:Promise = new Promise((resolve:Function) => { - setTimeout(() => { - let query = new RegExp(context.asyncSelected, 'ig'); - return resolve(context.states.filter((state:any) => { - return query.test(state); - })); - }, 200); - }); - return p; - }; - this._cache = f; - return this._cache; + observer.next(this.statesComplex.filter((state:any) => { + return query.test(state.name); + })); + }); } public changeTypeaheadLoading(e:boolean):void { @@ -92,6 +77,6 @@ export class TypeaheadDemoComponent { } public typeaheadOnSelect(e:any):void { - console.log(`Selected value: ${e.item}`); + console.log('Selected value: ',e.item); } }