Skip to content

Commit

Permalink
feat(typeahead): add selectOnExact option
Browse files Browse the repository at this point in the history
The description from the old docs in angular-ui:
typeahead-select-on-exact (Default: false) - Automatically select the item when it is the only one that exactly matches the user input.

Fixes #4371
  • Loading branch information
mibart authored and maxokorokov committed May 23, 2023
1 parent 417314a commit 4eb6cc7
Show file tree
Hide file tree
Showing 8 changed files with 256 additions and 18 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
A typeahead example that selects the item when it is the only one that matches the user input

<label for="typeahead-select-on-exact">Search for a state:</label>
<input id="typeahead-select-on-exact" type="text" class="form-control" [(ngModel)]="model" [ngbTypeahead]="search" />
<hr />
<pre>Model: {{ model | json }}</pre>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { BrowserModule } from '@angular/platform-browser';
import { NgbdTypeaheadSelectOnExact } from './typeahead-select-on-exact';
import { NgbModule } from '@ng-bootstrap/ng-bootstrap';

@NgModule({
imports: [BrowserModule, FormsModule, NgbModule],
declarations: [NgbdTypeaheadSelectOnExact],
exports: [NgbdTypeaheadSelectOnExact],
bootstrap: [NgbdTypeaheadSelectOnExact],
})
export class NgbdTypeaheadSelectOnExactModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Component } from '@angular/core';
import { Observable, OperatorFunction } from 'rxjs';
import { debounceTime, distinctUntilChanged, map } from 'rxjs/operators';

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-select-on-exact',
templateUrl: './typeahead-select-on-exact.html',
styles: [
`
.form-control {
width: 300px;
}
`,
],
})
export class NgbdTypeaheadSelectOnExact {
public model: any;

search: OperatorFunction<string, readonly string[]> = (text$: Observable<string>) =>
text$.pipe(
debounceTime(200),
distinctUntilChanged(),
map((term) =>
term.length < 2 ? [] : states.filter((v) => v.toLowerCase().indexOf(term.toLowerCase()) > -1).slice(0, 10),
),
);
}
7 changes: 7 additions & 0 deletions demo/src/app/components/typeahead/typeahead.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { NgbdTypeaheadConfig } from './demos/config/typeahead-config';
import { NgbdTypeaheadFocus } from './demos/focus/typeahead-focus';
import { NgbdTypeaheadFormat } from './demos/format/typeahead-format';
import { NgbdTypeaheadHttp } from './demos/http/typeahead-http';
import { NgbdTypeaheadSelectOnExact } from './demos/select-on-exact/typeahead-select-on-exact';
import { NgbdTypeaheadTemplate } from './demos/template/typeahead-template';
import { NgbdTypeaheadPreventManualEntry } from './demos/prevent-manual-entry/typeahead-prevent-manual-entry';
import { Routes } from '@angular/router';
Expand All @@ -20,6 +21,12 @@ const DEMOS = {
code: require('!!raw-loader!./demos/basic/typeahead-basic').default,
markup: require('!!raw-loader!./demos/basic/typeahead-basic.html').default,
},
'select-on-exact': {
title: 'Select on exact',
type: NgbdTypeaheadSelectOnExact,
code: require('!!raw-loader!./demos/select-on-exact/typeahead-select-on-exact').default,
markup: require('!!raw-loader!./demos/select-on-exact/typeahead-select-on-exact.html').default,
},
focus: {
title: 'Open on focus',
type: NgbdTypeaheadFocus,
Expand Down
1 change: 1 addition & 0 deletions src/typeahead/typeahead-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ describe('ngb-typeahead-config', () => {
expect(config.container).toBeUndefined();
expect(config.editable).toBeTruthy();
expect(config.focusFirst).toBeTruthy();
expect(config.selectOnExact).toBeFalsy();
expect(config.showHint).toBeFalsy();
expect(config.placement).toEqual(['bottom-start', 'bottom-end', 'top-start', 'top-end']);
expect(config.popperOptions({})).toEqual({});
Expand Down
1 change: 1 addition & 0 deletions src/typeahead/typeahead-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export class NgbTypeaheadConfig {
container;
editable = true;
focusFirst = true;
selectOnExact = false;
showHint = false;
placement: PlacementArray = ['bottom-start', 'bottom-end', 'top-start', 'top-end'];
popperOptions = (options: Partial<Options>) => options;
Expand Down
102 changes: 102 additions & 0 deletions src/typeahead/typeahead.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,104 @@ describe('ngb-typeahead', () => {
expect(typeahead.showHint).toBe(true);
});
});

describe('selectOnExact set to true', () => {
let fixture;
let compiled;
let inputEl;
beforeEach(() => {
fixture = createTestComponent(
`<input type='text' [(ngModel)]='model' [ngbTypeahead]='findAnywhere' [selectOnExact]='true'/>`,
);
compiled = fixture.nativeElement;
inputEl = getNativeInput(compiled);
});

it('should select the only existing result when it matches the user input', fakeAsync(() => {
tick();
changeInput(compiled, 'one more');
fixture.detectChanges();
expect(getWindow(compiled)).toBeNull();
expect(inputEl.value).toBe('one more');
}));

it('should not select the only existing result when it doesn`t match the user input', fakeAsync(() => {
tick();
changeInput(compiled, 'one mor');
fixture.detectChanges();
expectWindowResults(compiled, ['+one more']);
expect(inputEl.value).toBe('one mor');
}));
});

describe('selectOnExact set to true with objects', () => {
let fixture;
let compiled;
let inputEl;
beforeEach(() => {
fixture = createTestComponent(
`<input
[(ngModel)]='model'
[ngbTypeahead]='findObjectsFormatter'
[selectOnExact]='true'
[inputFormatter]='formatter'
[resultFormatter]='formatter'/>`,
);
compiled = fixture.nativeElement;
inputEl = getNativeInput(compiled);
});

it('should select the only existing result when it matches the user input', fakeAsync(() => {
tick();
changeInput(compiled, '10 one more');
fixture.detectChanges();
expect(getWindow(compiled)).toBeNull();
expect(inputEl.value).toBe('10 one more');
expect(fixture.componentInstance.model).toEqual({ id: 10, value: 'one more' });
}));

it('should not select the only existing result when it doesn`t match the user input', fakeAsync(() => {
tick();
changeInput(compiled, '10 one mor');
fixture.detectChanges();
expectWindowResults(compiled, ['+10 one more']);
expect(inputEl.value).toBe('10 one mor');
}));
});

describe('selectOnExact set to true and editable set to false', () => {
let fixture;
let compiled;
let inputEl;
beforeEach(() => {
fixture = createTestComponent(
`
<form [formGroup]='form'>
<input type='text' formControlName='control' [ngbTypeahead]='findAnywhere' [selectOnExact]='true' [editable]='false'/>
</form>`,
);
compiled = fixture.nativeElement;
inputEl = getNativeInput(compiled);
});

it('should select the only existing result when it matches the user input', fakeAsync(() => {
tick();
changeInput(compiled, 'one more');
fixture.detectChanges();
expect(getWindow(compiled)).toBeNull();
expect(fixture.componentInstance.form.controls.control.value).toBe('one more');
expect(fixture.componentInstance.form.controls.control.valid).toBeTrue();
}));

it('should not select the only existing result when it doesn`t match the user input', fakeAsync(() => {
tick();
changeInput(compiled, 'one mor');
fixture.detectChanges();
expectWindowResults(compiled, ['+one more']);
expect(fixture.componentInstance.form.controls.control.value).toBeUndefined();
expect(fixture.componentInstance.form.controls.control.valid).toBeFalse();
}));
});
});

@Component({
Expand Down Expand Up @@ -1052,6 +1150,10 @@ class TestComponent {
return text$.pipe(map((text) => this._objects.filter((v) => v.value.startsWith(text))));
};

findObjectsFormatter = (text$: Observable<string>) => {
return text$.pipe(map((text) => this._objects.filter((v) => this.formatter(v).startsWith(text))));
};

formatter = (obj: { id: number; value: string }) => {
return `${obj.id} ${obj.value}`;
};
Expand Down
55 changes: 37 additions & 18 deletions src/typeahead/typeahead.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export class NgbTypeahead implements ControlValueAccessor, OnInit, OnChanges, On
private _subscription: Subscription | null = null;
private _closed$ = new Subject<void>();
private _inputValueBackup: string | null = null;
private _inputValueForSelectOnExact: string | null = null;
private _valueChanges: Observable<string>;
private _resubscribeTypeahead: BehaviorSubject<any>;
private _windowRef: ComponentRef<NgbTypeaheadWindow> | null = null;
Expand Down Expand Up @@ -112,6 +113,13 @@ export class NgbTypeahead implements ControlValueAccessor, OnInit, OnChanges, On
*/
@Input() focusFirst: boolean;

/**
* If `true`, automatically selects the item when it is the only one that exactly matches the user input
*
* @since 13.2.0
*/
@Input() selectOnExact: boolean;

/**
* The function that converts an item from the result list to a `string` to display in the `<input>` field.
*
Expand Down Expand Up @@ -213,6 +221,7 @@ export class NgbTypeahead implements ControlValueAccessor, OnInit, OnChanges, On
this.container = config.container;
this.editable = config.editable;
this.focusFirst = config.focusFirst;
this.selectOnExact = config.selectOnExact;
this.showHint = config.showHint;
this.placement = config.placement;
this.popperOptions = config.popperOptions;
Expand Down Expand Up @@ -425,6 +434,7 @@ export class NgbTypeahead implements ControlValueAccessor, OnInit, OnChanges, On
const results$ = this._valueChanges.pipe(
tap((value) => {
this._inputValueBackup = this.showHint ? value : null;
this._inputValueForSelectOnExact = this.selectOnExact ? value : null;
this._onChange(this.editable ? value : undefined);
}),
this.ngbTypeahead ? this.ngbTypeahead : () => of([]),
Expand All @@ -434,25 +444,34 @@ export class NgbTypeahead implements ControlValueAccessor, OnInit, OnChanges, On
if (!results || results.length === 0) {
this._closePopup();
} else {
this._openPopup();

this._windowRef!.instance.focusFirst = this.focusFirst;
this._windowRef!.instance.results = results;
this._windowRef!.instance.term = this._elementRef.nativeElement.value;
if (this.resultFormatter) {
this._windowRef!.instance.formatter = this.resultFormatter;
// when there is only one result and this matches the input value
if (
this.selectOnExact &&
results.length === 1 &&
this._formatItemForInput(results[0]) === this._inputValueForSelectOnExact
) {
this._selectResult(results[0]);
this._closePopup();
} else {
this._openPopup();
this._windowRef!.instance.focusFirst = this.focusFirst;
this._windowRef!.instance.results = results;
this._windowRef!.instance.term = this._elementRef.nativeElement.value;
if (this.resultFormatter) {
this._windowRef!.instance.formatter = this.resultFormatter;
}
if (this.resultTemplate) {
this._windowRef!.instance.resultTemplate = this.resultTemplate;
}
this._windowRef!.instance.resetActive();

// The observable stream we are subscribing to might have async steps
// and if a component containing typeahead is using the OnPush strategy
// the change detection turn wouldn't be invoked automatically.
this._windowRef!.changeDetectorRef.detectChanges();

this._showHint();
}
if (this.resultTemplate) {
this._windowRef!.instance.resultTemplate = this.resultTemplate;
}
this._windowRef!.instance.resetActive();

// The observable stream we are subscribing to might have async steps
// and if a component containing typeahead is using the OnPush strategy
// the change detection turn wouldn't be invoked automatically.
this._windowRef!.changeDetectorRef.detectChanges();

this._showHint();
}

// live announcer
Expand Down

0 comments on commit 4eb6cc7

Please sign in to comment.