Skip to content

Commit b42fcb9

Browse files
crisbetojelbourn
authored andcommitted
feat(autocomplete): add the ability to highlight the first option on open (#9495)
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 c306297 commit b42fcb9

File tree

3 files changed

+85
-20
lines changed

3 files changed

+85
-20
lines changed

src/lib/autocomplete/autocomplete-trigger.ts

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

528-
/** Reset active item to -1 so arrow events will activate the correct options. */
528+
/**
529+
* Resets the active item to -1 so arrow events will activate the
530+
* correct options, or to 0 if the consumer opted into it.
531+
*/
529532
private _resetActiveItem(): void {
530-
this.autocomplete._keyManager.setActiveItem(-1);
533+
this.autocomplete._keyManager.setActiveItem(this.autocomplete.autoActiveFirstOption ? 0 : -1);
531534
}
532535

533536
/** Determines whether the panel can be opened. */

src/lib/autocomplete/autocomplete.spec.ts

Lines changed: 42 additions & 17 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

@@ -410,9 +412,11 @@ describe('MatAutocomplete', () => {
410412
});
411413

412414
it('should have the correct text direction in RTL', () => {
413-
const rtlFixture = createComponent(SimpleAutocomplete, 'rtl');
414-
rtlFixture.detectChanges();
415+
const rtlFixture = createComponent(SimpleAutocomplete, [
416+
{provide: Directionality, useFactory: () => ({value: 'rtl'})},
417+
]);
415418

419+
rtlFixture.detectChanges();
416420
rtlFixture.componentInstance.trigger.openPanel();
417421
rtlFixture.detectChanges();
418422

@@ -1291,12 +1295,12 @@ describe('MatAutocomplete', () => {
12911295
beforeEach(() => {
12921296
fixture = createComponent(SimpleAutocomplete);
12931297
fixture.detectChanges();
1298+
});
12941299

1300+
it('should deselect any other selected option', fakeAsync(() => {
12951301
fixture.componentInstance.trigger.openPanel();
12961302
fixture.detectChanges();
1297-
});
12981303

1299-
it('should deselect any other selected option', fakeAsync(() => {
13001304
let options =
13011305
overlayContainerElement.querySelectorAll('mat-option') as NodeListOf<HTMLElement>;
13021306
options[0].click();
@@ -1320,6 +1324,9 @@ describe('MatAutocomplete', () => {
13201324
}));
13211325

13221326
it('should call deselect only on the previous selected option', fakeAsync(() => {
1327+
fixture.componentInstance.trigger.openPanel();
1328+
fixture.detectChanges();
1329+
13231330
let options =
13241331
overlayContainerElement.querySelectorAll('mat-option') as NodeListOf<HTMLElement>;
13251332
options[0].click();
@@ -1342,15 +1349,33 @@ describe('MatAutocomplete', () => {
13421349
componentOptions.slice(1).forEach(option => expect(option.deselect).not.toHaveBeenCalled());
13431350
}));
13441351

1345-
it('should emit an event when an option is selected', fakeAsync(() => {
1346-
const spy = jasmine.createSpy('option selection spy');
1347-
const subscription = fixture.componentInstance.trigger.optionSelections.subscribe(spy);
1348-
const option = overlayContainerElement.querySelector('mat-option') as HTMLElement;
1349-
option.click();
1352+
it('should be able to preselect the first option', fakeAsync(() => {
1353+
fixture.componentInstance.trigger.autocomplete.autoActiveFirstOption = true;
1354+
fixture.componentInstance.trigger.openPanel();
1355+
fixture.detectChanges();
1356+
zone.simulateZoneExit();
13501357
fixture.detectChanges();
13511358

1352-
expect(spy).toHaveBeenCalledWith(jasmine.any(MatOptionSelectionChange));
1353-
subscription.unsubscribe();
1359+
expect(overlayContainerElement.querySelectorAll('mat-option')[0].classList)
1360+
.toContain('mat-active', 'Expected first option to be highlighted.');
1361+
}));
1362+
1363+
it('should be able to configure preselecting the first option globally', fakeAsync(() => {
1364+
overlayContainer.ngOnDestroy();
1365+
fixture.destroy();
1366+
TestBed.resetTestingModule();
1367+
fixture = createComponent(SimpleAutocomplete, [
1368+
{provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: {autoActiveFirstOption: true}}
1369+
]);
1370+
1371+
fixture.detectChanges();
1372+
fixture.componentInstance.trigger.openPanel();
1373+
fixture.detectChanges();
1374+
zone.simulateZoneExit();
1375+
fixture.detectChanges();
1376+
1377+
expect(overlayContainerElement.querySelectorAll('mat-option')[0].classList)
1378+
.toContain('mat-active', 'Expected first option to be highlighted.');
13541379
}));
13551380

13561381
it('should handle `optionSelections` being accessed too early', fakeAsync(() => {
@@ -1743,8 +1768,8 @@ describe('MatAutocomplete', () => {
17431768
<input matInput placeholder="State" [matAutocomplete]="auto" [formControl]="stateCtrl">
17441769
</mat-form-field>
17451770
1746-
<mat-autocomplete class="class-one class-two" #auto="matAutocomplete"
1747-
[displayWith]="displayFn" [disableRipple]="disableRipple">
1771+
<mat-autocomplete class="class-one class-two" #auto="matAutocomplete" [displayWith]="displayFn"
1772+
[disableRipple]="disableRipple">
17481773
<mat-option *ngFor="let state of filteredStates" [value]="state">
17491774
<span> {{ state.code }}: {{ state.name }} </span>
17501775
</mat-option>

src/lib/autocomplete/autocomplete.ts

Lines changed: 38 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+
autoActiveFirstOption?: 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 autoActiveFirstOption(): boolean { return this._autoActiveFirstOption; }
121+
set autoActiveFirstOption(value: boolean) {
122+
this._autoActiveFirstOption = coerceBooleanProperty(value);
123+
}
124+
private _autoActiveFirstOption: boolean;
125+
126+
101127
/** Event that is emitted whenever an option from the list is selected. */
102128
@Output() readonly optionSelected: EventEmitter<MatAutocompleteSelectedEvent> =
103129
new EventEmitter<MatAutocompleteSelectedEvent>();
@@ -118,8 +144,19 @@ 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._autoActiveFirstOption = defaults &&
157+
typeof defaults.autoActiveFirstOption !== 'undefined' ?
158+
defaults.autoActiveFirstOption :
159+
false;
123160
}
124161

125162
ngAfterContentInit() {

0 commit comments

Comments
 (0)