Skip to content

Commit

Permalink
feat(typeahead): allow search on focus and click
Browse files Browse the repository at this point in the history
closes #698

Closes #1990
  • Loading branch information
ymeine authored and pkozlowski-opensource committed Dec 15, 2017
1 parent 9f519b1 commit 96d073d
Show file tree
Hide file tree
Showing 6 changed files with 198 additions and 11 deletions.
23 changes: 23 additions & 0 deletions demo/src/app/components/typeahead/demos/focus/typeahead-focus.html
@@ -0,0 +1,23 @@
It is possible to get the focus events with the current input value to emit results on focus with a great flexibility.

In this simple example, a search is done no matter the content of the input:

<ul>
<li>on empty input all options will be taken</li>
<li>otherwise options will be filtered against the search term</li>
<li>it will limit the display to 10 results in all cases</li>
</ul>

<label for="typeahead-focus">Search for a state:</label>
<input
id="typeahead-focus"
type="text"
class="form-control"
[(ngModel)]="model"
[ngbTypeahead]="search"
(focus)="focus$.next($event.target.value)"
(click)="click$.next($event.target.value)"
#instance="ngbTypeahead"
/>
<hr>
<pre>Model: {{ model | json }}</pre>
38 changes: 38 additions & 0 deletions demo/src/app/components/typeahead/demos/focus/typeahead-focus.ts
@@ -0,0 +1,38 @@
import {Component, ViewChild} from '@angular/core';
import {NgbTypeahead} from '@ng-bootstrap/ng-bootstrap';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/merge';
import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/distinctUntilChanged';

const states = ['Alabama', 'Alaska', 'American Samoa', 'Arizona', 'Arkansas', 'California', 'Colorado',
'Connecticut', 'Delaware', 'District Of Columbia', 'Federated States Of Micronesia', 'Florida', 'Georgia',
'Guam', 'Hawaii', 'Idaho', 'Illinois', 'Indiana', 'Iowa', 'Kansas', 'Kentucky', 'Louisiana', 'Maine',
'Marshall Islands', 'Maryland', 'Massachusetts', 'Michigan', 'Minnesota', 'Mississippi', 'Missouri', 'Montana',
'Nebraska', 'Nevada', 'New Hampshire', 'New Jersey', 'New Mexico', 'New York', 'North Carolina', 'North Dakota',
'Northern Mariana Islands', 'Ohio', 'Oklahoma', 'Oregon', 'Palau', 'Pennsylvania', 'Puerto Rico', 'Rhode Island',
'South Carolina', 'South Dakota', 'Tennessee', 'Texas', 'Utah', 'Vermont', 'Virgin Islands', 'Virginia',
'Washington', 'West Virginia', 'Wisconsin', 'Wyoming'];

@Component({
selector: 'ngbd-typeahead-focus',
templateUrl: './typeahead-focus.html',
styles: [`.form-control { width: 300px; }`]
})
export class NgbdTypeaheadFocus {
model: any;

@ViewChild('instance') instance: NgbTypeahead;
focus$ = new Subject<string>();
click$ = new Subject<string>();

search = (text$: Observable<string>) =>
text$
.debounceTime(200).distinctUntilChanged()
.merge(this.focus$)
.merge(this.click$.filter(() => !this.instance.isPopupOpen()))
.map(term => (term === '' ? states : states.filter(v => v.toLowerCase().indexOf(term.toLowerCase()) > -1)).slice(0, 10));
}
7 changes: 6 additions & 1 deletion demo/src/app/components/typeahead/demos/index.ts
@@ -1,17 +1,22 @@
import {NgbdTypeaheadFormat} from './format/typeahead-format';
import {NgbdTypeaheadHttp} from './http/typeahead-http';
import {NgbdTypeaheadBasic} from './basic/typeahead-basic';
import {NgbdTypeaheadFocus} from './focus/typeahead-focus';
import {NgbdTypeaheadTemplate} from './template/typeahead-template';
import {NgbdTypeaheadConfig} from './config/typeahead-config';

export const DEMO_DIRECTIVES =
[NgbdTypeaheadFormat, NgbdTypeaheadHttp, NgbdTypeaheadBasic, NgbdTypeaheadTemplate, NgbdTypeaheadConfig];
[NgbdTypeaheadFormat, NgbdTypeaheadHttp, NgbdTypeaheadBasic, NgbdTypeaheadFocus, NgbdTypeaheadTemplate, NgbdTypeaheadConfig];

export const DEMO_SNIPPETS = {
'basic': {
'code': require('!!prismjs-loader?lang=typescript!./basic/typeahead-basic'),
'markup': require('!!prismjs-loader?lang=markup!./basic/typeahead-basic.html')
},
'focus': {
'code': require('!!prismjs-loader?lang=typescript!./focus/typeahead-focus'),
'markup': require('!!prismjs-loader?lang=markup!./focus/typeahead-focus.html')
},
'format': {
'code': require('!!prismjs-loader?lang=typescript!./format/typeahead-format'),
'markup': require('!!prismjs-loader?lang=markup!./format/typeahead-format.html')
Expand Down
3 changes: 3 additions & 0 deletions demo/src/app/components/typeahead/typeahead.component.ts
Expand Up @@ -12,6 +12,9 @@ import {DEMO_SNIPPETS} from './demos';
<ngbd-example-box demoTitle="Simple Typeahead" [snippets]="snippets" component="typeahead" demo="basic">
<ngbd-typeahead-basic></ngbd-typeahead-basic>
</ngbd-example-box>
<ngbd-example-box demoTitle="Open on focus" [snippets]="snippets" component="typeahead" demo="focus">
<ngbd-typeahead-focus></ngbd-typeahead-focus>
</ngbd-example-box>
<ngbd-example-box demoTitle="Formatted results" [snippets]="snippets" component="typeahead" demo="format">
<ngbd-typeahead-format></ngbd-typeahead-format>
</ngbd-example-box>
Expand Down
119 changes: 118 additions & 1 deletion src/typeahead/typeahead.spec.ts
Expand Up @@ -6,8 +6,11 @@ import {Component, DebugElement, ViewChild, ChangeDetectionStrategy} from '@angu
import {Validators, FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {By} from '@angular/platform-browser';
import {Observable} from 'rxjs/Observable';
import {Subject} from 'rxjs/Subject';
import 'rxjs/add/operator/map';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/merge';
import 'rxjs/add/operator/filter';

import {NgbTypeahead} from './typeahead';
import {NgbTypeaheadModule} from './typeahead.module';
Expand Down Expand Up @@ -194,6 +197,111 @@ describe('ngb-typeahead', () => {
expect(getWindow(compiled)).not.toBeNull();
});

describe('open on focus and click', () => {
const createFixture = () => createTestComponent(`<input
type="text"
[ngbTypeahead]="find"
(focus)="focus$.next($event.target.value)"
(click)="click$.next($event.target.value)"
/>`);

// on IE the focus & blur can be asynchronous, so we need to wait a bit before continuing
const delay = () => new Promise(resolve => setTimeout(resolve, 25));
const focus = async(input) => {
input.focus();
await delay();
};
const blur = async(input) => {
input.blur();
await delay();
};

it('should open on focus or click', async(async() => {
const fixture = createFixture();
const compiled = fixture.nativeElement;
const input = getNativeInput(compiled);

let searchCount = 0;
fixture.componentInstance.findOutput$.subscribe(() => searchCount++);
const checkSearchCount = (expected, context) => {
expect(searchCount).toBe(expected, `Search count is not correct: ${context}`);
searchCount = 0;
};

const checkWindowIsClosed = () => {
expect(getWindow(compiled)).toBeNull();
expect(fixture.componentInstance.model).toBe(undefined);
expect(input.value).toBe('');
};

const checkWindowIsOpen = () => { expect(getWindow(compiled)).not.toBeNull(); };

// focusing the input triggers a search and opens the dropdown
await focus(input);
checkSearchCount(1, 'on first focus');
checkWindowIsOpen();

// clicking again in the input while the dropdown is open doesn't trigger a new search and keeps the dropdown
// open
input.click();
checkSearchCount(0, 'on input click when dropdown already open');
checkWindowIsOpen();

// closing the dropdown but keeping focus
const event = createKeyDownEvent(Key.Escape);
getDebugInput(fixture.debugElement).triggerEventHandler('keydown', event);
fixture.detectChanges();
checkWindowIsClosed();

// clicking again in the input while already focused but dropdown closed triggers a search and opens the
// dropdown
input.click();
checkSearchCount(1, 'on input click when input is already focused but dropdown is closed');
checkWindowIsOpen();

// closing the dropdown and losing focus
fixture.nativeElement.click();
await blur(input);
checkWindowIsClosed();

// Clicking directly, putting focus at the same time, triggers only one search and opens the dropdown
input.click();
checkSearchCount(1, 'on input focus specifically with a click');
checkWindowIsOpen();
}));

it('should preserve value previously selected with mouse when reopening with focus then closing without selection',
async(async() => {
const fixture = createFixture();
const compiled = fixture.nativeElement;
const input = getNativeInput(compiled);

await fixture.whenStable();
// open with partial input
changeInput(compiled, 'o');
fixture.detectChanges();
expect(getWindow(compiled)).not.toBeNull('Window should be opened after typing in the input');

// select with click
getWindowLinks(fixture.debugElement)[0].triggerEventHandler('click', {});
fixture.detectChanges();
expectInputValue(compiled, 'one');
expect(getWindow(compiled)).toBeNull('Window should be closed after selecting option with the mouse');

// open again but with focus
await blur(input);
await focus(input);
expect(getWindow(compiled)).not.toBeNull('Window should be opened after focusing back the input');

// close without selecting a new value
const event = createKeyDownEvent(Key.Escape);
getDebugInput(fixture.debugElement).triggerEventHandler('keydown', event);
fixture.detectChanges();
expect(getWindow(compiled)).toBeNull('Window should be closed after pressing escape');
expectInputValue(compiled, 'one');
}));
});

it('should be closed when ESC is pressed', () => {
const fixture = createTestComponent(`<input type="text" [ngbTypeahead]="find"/>`);
const compiled = fixture.nativeElement;
Expand Down Expand Up @@ -921,9 +1029,18 @@ class TestComponent {

form = new FormGroup({control: new FormControl('', Validators.required)});

findOutput$: Observable<any[]>;

@ViewChild(NgbTypeahead) typeahead: NgbTypeahead;
focus$ = new Subject<string>();
click$ = new Subject<string>();

find = (text$: Observable<string>) => { return text$.map(text => this._strings.filter(v => v.startsWith(text))); };
find = (text$: Observable<string>) => {
this.findOutput$ = text$.merge(this.focus$)
.merge(this.click$.filter(() => !this.typeahead.isPopupOpen()))
.map(text => this._strings.filter(v => v.startsWith(text)));
return this.findOutput$;
};

findAnywhere =
(text$: Observable<string>) => { return text$.map(text => this._strings.filter(v => v.indexOf(text) > -1)); };
Expand Down
19 changes: 10 additions & 9 deletions src/typeahead/typeahead.ts
Expand Up @@ -87,7 +87,7 @@ export class NgbTypeahead implements ControlValueAccessor,
OnInit, OnDestroy {
private _popupService: PopupService<NgbTypeaheadWindow>;
private _subscription: Subscription;
private _userInput: string;
private _inputValueBackup: string;
private _valueChanges: Observable<string>;
private _resubscribeTypeahead: BehaviorSubject<any>;
private _windowRef: ComponentRef<NgbTypeaheadWindow>;
Expand Down Expand Up @@ -183,7 +183,7 @@ export class NgbTypeahead implements ControlValueAccessor,

ngOnInit(): void {
const inputValues$ = _do.call(this._valueChanges, value => {
this._userInput = value;
this._inputValueBackup = value;
if (this.editable) {
this._onChange(value);
}
Expand Down Expand Up @@ -226,7 +226,7 @@ export class NgbTypeahead implements ControlValueAccessor,
dismissPopup() {
if (this.isPopupOpen()) {
this._closePopup();
this._writeInputValue(this._userInput);
this._writeInputValue(this._inputValueBackup);
}
}

Expand Down Expand Up @@ -278,6 +278,7 @@ export class NgbTypeahead implements ControlValueAccessor,

private _openPopup() {
if (!this.isPopupOpen()) {
this._inputValueBackup = this._elementRef.nativeElement.value;
this._windowRef = this._popupService.open();
this._windowRef.instance.id = this.popupId;
this._windowRef.instance.selectEvent.subscribe((result: any) => this._selectResultClosePopup(result));
Expand Down Expand Up @@ -312,14 +313,14 @@ export class NgbTypeahead implements ControlValueAccessor,
}

private _showHint() {
if (this.showHint) {
const userInputLowerCase = this._userInput.toLowerCase();
if (this.showHint && this._inputValueBackup != null) {
const userInputLowerCase = this._inputValueBackup.toLowerCase();
const formattedVal = this._formatItemForInput(this._windowRef.instance.getActive());

if (userInputLowerCase === formattedVal.substr(0, this._userInput.length).toLowerCase()) {
this._writeInputValue(this._userInput + formattedVal.substr(this._userInput.length));
if (userInputLowerCase === formattedVal.substr(0, this._inputValueBackup.length).toLowerCase()) {
this._writeInputValue(this._inputValueBackup + formattedVal.substr(this._inputValueBackup.length));
this._elementRef.nativeElement['setSelectionRange'].apply(
this._elementRef.nativeElement, [this._userInput.length, formattedVal.length]);
this._elementRef.nativeElement, [this._inputValueBackup.length, formattedVal.length]);
} else {
this.writeValue(this._windowRef.instance.getActive());
}
Expand All @@ -331,7 +332,7 @@ export class NgbTypeahead implements ControlValueAccessor,
}

private _writeInputValue(value: string): void {
this._renderer.setProperty(this._elementRef.nativeElement, 'value', value);
this._renderer.setProperty(this._elementRef.nativeElement, 'value', toString(value));
}

private _subscribeToUserInput(userInput$: Observable<any[]>): Subscription {
Expand Down

0 comments on commit 96d073d

Please sign in to comment.