Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat typeahead rxjs #584

Merged
merged 5 commits into from
Jun 23, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion components/typeahead/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { TYPEAHEAD_DIRECTIVES } from 'ng2-bootstrap/ng2-bootstrap';
```typescript
// directive Typeahead
@Directive({
selector: 'typeahead, [typeahead]'
selector: '[typeahead][ngModel]'
})
export class TypeaheadDirective implements OnInit {
@Output() public typeaheadLoading:EventEmitter<boolean>;
Expand Down
8 changes: 5 additions & 3 deletions components/typeahead/typeahead-container.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
245 changes: 95 additions & 150 deletions components/typeahead/typeahead.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -48,8 +56,8 @@ export class TypeaheadDirective implements OnInit {
public container:TypeaheadContainerComponent;
public isTypeaheadOptionsListActive:boolean = false;

private debouncer:Function;
private _matches:Array<any> = [];
private keyUpEventEmitter:EventEmitter<any> = new EventEmitter();
private _matches:Array<string>;
private placement:string = 'bottom-left';
private popup:Promise<ComponentRef<any>>;

Expand All @@ -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) {
Expand All @@ -87,43 +95,26 @@ 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();
}
}

@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();
Expand Down Expand Up @@ -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<any> {
return this._matches;
}

public show(matches:Array<any>):void {
let options = new TypeaheadOptions({
typeaheadRef: this,
Expand Down Expand Up @@ -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<any> {
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<any>;
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 {
Expand All @@ -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();
Expand All @@ -399,4 +343,5 @@ export class TypeaheadDirective implements OnInit {
this.show(this._matches);
}
}

}
6 changes: 3 additions & 3 deletions demo/components/typeahead/typeahead-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@
<h4>Static arrays</h4>
<pre class="card card-block card-header">Model: {{selected | json}}</pre>
<input [(ngModel)]="selected"
[typeahead]="statesComplex"
[typeahead]="states"
(typeaheadOnSelect)="typeaheadOnSelect($event)"
[typeaheadOptionField]="'name'"
class="form-control">

<h4>Asynchronous results</h4>
<pre class="card card-block card-header">Model: {{asyncSelected | json}}</pre>
<input [(ngModel)]="asyncSelected"
[typeahead]="getAsyncData(getContext())"
[typeahead]="dataSource"
(typeaheadLoading)="changeTypeaheadLoading($event)"
(typeaheadNoResults)="changeTypeaheadNoResults($event)"
(typeaheadOnSelect)="typeaheadOnSelect($event)"
[typeaheadOptionsLimit]="7"
[typeaheadOptionField]="'name'"
placeholder="Locations loaded with timeout"
class="form-control">
<div *ngIf="typeaheadLoading===true">
Expand Down
Loading