Skip to content

Commit e2ff050

Browse files
committed
feat(autocomplete): add the ability to highlight the first option on open
Adds the ability for the consumer opt-in to having the autocomplete highlight the first option when opened. Includes an injection token that allows it to be configured globally. Fixes #8423.
1 parent 4523556 commit e2ff050

File tree

3 files changed

+88
-13
lines changed

3 files changed

+88
-13
lines changed

src/lib/autocomplete/autocomplete-trigger.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -508,9 +508,12 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
508508
return this._getConnectedElement().nativeElement.getBoundingClientRect().width;
509509
}
510510

511-
/** Reset active item to -1 so arrow events will activate the correct options. */
511+
/**
512+
* Resets the active item to -1 so arrow events will activate the
513+
* correct options, or to 0 if the consumer opted into it.
514+
*/
512515
private _resetActiveItem(): void {
513-
this.autocomplete._keyManager.setActiveItem(-1);
516+
this.autocomplete._keyManager.setActiveItem(this.autocomplete.highlightFirstOption ? 0 : -1);
514517
}
515518

516519
}

src/lib/autocomplete/autocomplete.spec.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Direction, Directionality} from '@angular/cdk/bidi';
1+
import {Directionality} from '@angular/cdk/bidi';
22
import {DOWN_ARROW, ENTER, ESCAPE, SPACE, UP_ARROW, TAB} from '@angular/cdk/keycodes';
33
import {OverlayContainer, Overlay} from '@angular/cdk/overlay';
44
import {map} from 'rxjs/operators/map';
@@ -20,6 +20,7 @@ import {
2020
ViewChild,
2121
ViewChildren,
2222
NgZone,
23+
Provider,
2324
} from '@angular/core';
2425
import {
2526
async,
@@ -46,6 +47,7 @@ import {
4647
MatAutocompleteSelectedEvent,
4748
MatAutocompleteTrigger,
4849
MAT_AUTOCOMPLETE_SCROLL_STRATEGY,
50+
MAT_AUTOCOMPLETE_DEFAULT_OPTIONS,
4951
} from './index';
5052

5153

@@ -56,7 +58,7 @@ describe('MatAutocomplete', () => {
5658
let zone: MockNgZone;
5759

5860
// Creates a test component fixture.
59-
function createComponent(component: any, dir: Direction = 'ltr'): ComponentFixture<any> {
61+
function createComponent(component: any, providers: Provider[] = []): ComponentFixture<any> {
6062
TestBed.configureTestingModule({
6163
imports: [
6264
MatAutocompleteModule,
@@ -68,14 +70,14 @@ describe('MatAutocomplete', () => {
6870
],
6971
declarations: [component],
7072
providers: [
71-
{provide: Directionality, useFactory: () => ({value: dir})},
7273
{provide: ScrollDispatcher, useFactory: () => ({
7374
scrolled: () => scrolledSubject.asObservable()
7475
})},
7576
{provide: NgZone, useFactory: () => {
7677
zone = new MockNgZone();
7778
return zone;
78-
}}
79+
}},
80+
...providers
7981
]
8082
});
8183

@@ -397,9 +399,11 @@ describe('MatAutocomplete', () => {
397399
});
398400

399401
it('should have the correct text direction in RTL', () => {
400-
const rtlFixture = createComponent(SimpleAutocomplete, 'rtl');
401-
rtlFixture.detectChanges();
402+
const rtlFixture = createComponent(SimpleAutocomplete, [
403+
{provide: Directionality, useFactory: () => ({value: 'rtl'})},
404+
]);
402405

406+
rtlFixture.detectChanges();
403407
rtlFixture.componentInstance.trigger.openPanel();
404408
rtlFixture.detectChanges();
405409

@@ -1259,12 +1263,12 @@ describe('MatAutocomplete', () => {
12591263
beforeEach(() => {
12601264
fixture = createComponent(SimpleAutocomplete);
12611265
fixture.detectChanges();
1266+
});
12621267

1268+
it('should deselect any other selected option', fakeAsync(() => {
12631269
fixture.componentInstance.trigger.openPanel();
12641270
fixture.detectChanges();
1265-
});
12661271

1267-
it('should deselect any other selected option', fakeAsync(() => {
12681272
let options =
12691273
overlayContainerElement.querySelectorAll('mat-option') as NodeListOf<HTMLElement>;
12701274
options[0].click();
@@ -1288,6 +1292,9 @@ describe('MatAutocomplete', () => {
12881292
}));
12891293

12901294
it('should call deselect only on the previous selected option', fakeAsync(() => {
1295+
fixture.componentInstance.trigger.openPanel();
1296+
fixture.detectChanges();
1297+
12911298
let options =
12921299
overlayContainerElement.querySelectorAll('mat-option') as NodeListOf<HTMLElement>;
12931300
options[0].click();
@@ -1309,6 +1316,35 @@ describe('MatAutocomplete', () => {
13091316
expect(componentOptions[0].deselect).toHaveBeenCalled();
13101317
componentOptions.slice(1).forEach(option => expect(option.deselect).not.toHaveBeenCalled());
13111318
}));
1319+
1320+
it('should be able to preselect the first option', fakeAsync(() => {
1321+
fixture.componentInstance.trigger.autocomplete.highlightFirstOption = true;
1322+
fixture.componentInstance.trigger.openPanel();
1323+
fixture.detectChanges();
1324+
zone.simulateZoneExit();
1325+
fixture.detectChanges();
1326+
1327+
expect(overlayContainerElement.querySelectorAll('mat-option')[0].classList)
1328+
.toContain('mat-active', 'Expected first option to be highlighted.');
1329+
}));
1330+
1331+
it('should be able to configure preselecting the first option globally', fakeAsync(() => {
1332+
overlayContainer.ngOnDestroy();
1333+
fixture.destroy();
1334+
TestBed.resetTestingModule();
1335+
fixture = createComponent(SimpleAutocomplete, [
1336+
{provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: {highlightFirstOption: true}}
1337+
]);
1338+
1339+
fixture.detectChanges();
1340+
fixture.componentInstance.trigger.openPanel();
1341+
fixture.detectChanges();
1342+
zone.simulateZoneExit();
1343+
fixture.detectChanges();
1344+
1345+
expect(overlayContainerElement.querySelectorAll('mat-option')[0].classList)
1346+
.toContain('mat-active', 'Expected first option to be highlighted.');
1347+
}));
13121348
});
13131349

13141350
describe('panel closing', () => {
@@ -1672,8 +1708,8 @@ describe('MatAutocomplete', () => {
16721708
<input matInput placeholder="State" [matAutocomplete]="auto" [formControl]="stateCtrl">
16731709
</mat-form-field>
16741710
1675-
<mat-autocomplete class="class-one class-two" #auto="matAutocomplete"
1676-
[displayWith]="displayFn" [disableRipple]="disableRipple">
1711+
<mat-autocomplete class="class-one class-two" #auto="matAutocomplete" [displayWith]="displayFn"
1712+
[disableRipple]="disableRipple">
16771713
<mat-option *ngFor="let state of filteredStates" [value]="state">
16781714
<span> {{ state.code }}: {{ state.name }} </span>
16791715
</mat-option>

src/lib/autocomplete/autocomplete.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import {
1919
ChangeDetectionStrategy,
2020
EventEmitter,
2121
Output,
22+
InjectionToken,
23+
Inject,
24+
Optional,
2225
} from '@angular/core';
2326
import {
2427
MatOption,
@@ -28,6 +31,7 @@ import {
2831
CanDisableRipple,
2932
} from '@angular/material/core';
3033
import {ActiveDescendantKeyManager} from '@angular/cdk/a11y';
34+
import {coerceBooleanProperty} from '@angular/cdk/coercion';
3135

3236

3337
/**
@@ -50,6 +54,16 @@ export class MatAutocompleteSelectedEvent {
5054
export class MatAutocompleteBase {}
5155
export const _MatAutocompleteMixinBase = mixinDisableRipple(MatAutocompleteBase);
5256

57+
/** Default `mat-autocomplete` options that can be overridden. */
58+
export interface MatAutocompleteDefaultOptions {
59+
/** Whether the first option should be highlighted when an autocomplete panel is opened. */
60+
highlightFirstOption?: boolean;
61+
}
62+
63+
/** Injection token to be used to override the default options for `mat-autocomplete`. */
64+
export const MAT_AUTOCOMPLETE_DEFAULT_OPTIONS =
65+
new InjectionToken<MatAutocompleteDefaultOptions>('mat-autocomplete-default-options');
66+
5367

5468
@Component({
5569
moduleId: module.id,
@@ -98,6 +112,18 @@ export class MatAutocomplete extends _MatAutocompleteMixinBase implements AfterC
98112
/** Function that maps an option's control value to its display value in the trigger. */
99113
@Input() displayWith: ((value: any) => string) | null = null;
100114

115+
/**
116+
* Whether the first option should be highlighted when the autocomplete panel is opened.
117+
* Can be configured globally through the `MAT_AUTOCOMPLETE_DEFAULT_OPTIONS` token.
118+
*/
119+
@Input()
120+
get highlightFirstOption(): boolean { return this._highlightFirstOption; }
121+
set highlightFirstOption(value: boolean) {
122+
this._highlightFirstOption = coerceBooleanProperty(value);
123+
}
124+
private _highlightFirstOption: boolean;
125+
126+
101127
/** Event that is emitted whenever an option from the list is selected. */
102128
@Output() optionSelected: EventEmitter<MatAutocompleteSelectedEvent> =
103129
new EventEmitter<MatAutocompleteSelectedEvent>();
@@ -118,8 +144,18 @@ export class MatAutocomplete extends _MatAutocompleteMixinBase implements AfterC
118144
/** Unique ID to be used by autocomplete trigger's "aria-owns" property. */
119145
id: string = `mat-autocomplete-${_uniqueAutocompleteIdCounter++}`;
120146

121-
constructor(private _changeDetectorRef: ChangeDetectorRef, private _elementRef: ElementRef) {
147+
constructor(
148+
private _changeDetectorRef: ChangeDetectorRef,
149+
private _elementRef: ElementRef,
150+
151+
// @deletion-target Turn into required param in 6.0.0
152+
@Optional() @Inject(MAT_AUTOCOMPLETE_DEFAULT_OPTIONS)
153+
defaults?: MatAutocompleteDefaultOptions) {
122154
super();
155+
156+
this._highlightFirstOption = defaults && typeof defaults.highlightFirstOption !== 'undefined' ?
157+
defaults.highlightFirstOption :
158+
false;
123159
}
124160

125161
ngAfterContentInit() {

0 commit comments

Comments
 (0)