Skip to content

feat(autocomplete): add the ability to highlight the first option on open #9495

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

Merged
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
7 changes: 5 additions & 2 deletions src/lib/autocomplete/autocomplete-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,9 +525,12 @@ export class MatAutocompleteTrigger implements ControlValueAccessor, OnDestroy {
return this._getConnectedElement().nativeElement.getBoundingClientRect().width;
}

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

/** Determines whether the panel can be opened. */
Expand Down
59 changes: 42 additions & 17 deletions src/lib/autocomplete/autocomplete.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Direction, Directionality} from '@angular/cdk/bidi';
import {Directionality} from '@angular/cdk/bidi';
import {DOWN_ARROW, ENTER, ESCAPE, SPACE, UP_ARROW, TAB} from '@angular/cdk/keycodes';
import {OverlayContainer, Overlay} from '@angular/cdk/overlay';
import {map} from 'rxjs/operators/map';
Expand All @@ -20,6 +20,7 @@ import {
ViewChild,
ViewChildren,
NgZone,
Provider,
} from '@angular/core';
import {
async,
Expand All @@ -46,6 +47,7 @@ import {
MatAutocompleteSelectedEvent,
MatAutocompleteTrigger,
MAT_AUTOCOMPLETE_SCROLL_STRATEGY,
MAT_AUTOCOMPLETE_DEFAULT_OPTIONS,
} from './index';


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

// Creates a test component fixture.
function createComponent(component: any, dir: Direction = 'ltr'): ComponentFixture<any> {
function createComponent(component: any, providers: Provider[] = []): ComponentFixture<any> {
TestBed.configureTestingModule({
imports: [
MatAutocompleteModule,
Expand All @@ -68,14 +70,14 @@ describe('MatAutocomplete', () => {
],
declarations: [component],
providers: [
{provide: Directionality, useFactory: () => ({value: dir})},
{provide: ScrollDispatcher, useFactory: () => ({
scrolled: () => scrolledSubject.asObservable()
})},
{provide: NgZone, useFactory: () => {
zone = new MockNgZone();
return zone;
}}
}},
...providers
]
});

Expand Down Expand Up @@ -410,9 +412,11 @@ describe('MatAutocomplete', () => {
});

it('should have the correct text direction in RTL', () => {
const rtlFixture = createComponent(SimpleAutocomplete, 'rtl');
rtlFixture.detectChanges();
const rtlFixture = createComponent(SimpleAutocomplete, [
{provide: Directionality, useFactory: () => ({value: 'rtl'})},
]);

rtlFixture.detectChanges();
rtlFixture.componentInstance.trigger.openPanel();
rtlFixture.detectChanges();

Expand Down Expand Up @@ -1291,12 +1295,12 @@ describe('MatAutocomplete', () => {
beforeEach(() => {
fixture = createComponent(SimpleAutocomplete);
fixture.detectChanges();
});

it('should deselect any other selected option', fakeAsync(() => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();
});

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

it('should call deselect only on the previous selected option', fakeAsync(() => {
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();

let options =
overlayContainerElement.querySelectorAll('mat-option') as NodeListOf<HTMLElement>;
options[0].click();
Expand All @@ -1342,15 +1349,33 @@ describe('MatAutocomplete', () => {
componentOptions.slice(1).forEach(option => expect(option.deselect).not.toHaveBeenCalled());
}));

it('should emit an event when an option is selected', fakeAsync(() => {
const spy = jasmine.createSpy('option selection spy');
const subscription = fixture.componentInstance.trigger.optionSelections.subscribe(spy);
const option = overlayContainerElement.querySelector('mat-option') as HTMLElement;
option.click();
it('should be able to preselect the first option', fakeAsync(() => {
fixture.componentInstance.trigger.autocomplete.autoActiveFirstOption = true;
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();
zone.simulateZoneExit();
fixture.detectChanges();

expect(spy).toHaveBeenCalledWith(jasmine.any(MatOptionSelectionChange));
subscription.unsubscribe();
expect(overlayContainerElement.querySelectorAll('mat-option')[0].classList)
.toContain('mat-active', 'Expected first option to be highlighted.');
}));

it('should be able to configure preselecting the first option globally', fakeAsync(() => {
overlayContainer.ngOnDestroy();
fixture.destroy();
TestBed.resetTestingModule();
fixture = createComponent(SimpleAutocomplete, [
{provide: MAT_AUTOCOMPLETE_DEFAULT_OPTIONS, useValue: {autoActiveFirstOption: true}}
]);

fixture.detectChanges();
fixture.componentInstance.trigger.openPanel();
fixture.detectChanges();
zone.simulateZoneExit();
fixture.detectChanges();

expect(overlayContainerElement.querySelectorAll('mat-option')[0].classList)
.toContain('mat-active', 'Expected first option to be highlighted.');
}));

it('should handle `optionSelections` being accessed too early', fakeAsync(() => {
Expand Down Expand Up @@ -1743,8 +1768,8 @@ describe('MatAutocomplete', () => {
<input matInput placeholder="State" [matAutocomplete]="auto" [formControl]="stateCtrl">
</mat-form-field>

<mat-autocomplete class="class-one class-two" #auto="matAutocomplete"
[displayWith]="displayFn" [disableRipple]="disableRipple">
<mat-autocomplete class="class-one class-two" #auto="matAutocomplete" [displayWith]="displayFn"
[disableRipple]="disableRipple">
<mat-option *ngFor="let state of filteredStates" [value]="state">
<span> {{ state.code }}: {{ state.name }} </span>
</mat-option>
Expand Down
39 changes: 38 additions & 1 deletion src/lib/autocomplete/autocomplete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import {
ChangeDetectionStrategy,
EventEmitter,
Output,
InjectionToken,
Inject,
Optional,
} from '@angular/core';
import {
MatOption,
Expand All @@ -28,6 +31,7 @@ import {
CanDisableRipple,
} from '@angular/material/core';
import {ActiveDescendantKeyManager} from '@angular/cdk/a11y';
import {coerceBooleanProperty} from '@angular/cdk/coercion';


/**
Expand All @@ -50,6 +54,16 @@ export class MatAutocompleteSelectedEvent {
export class MatAutocompleteBase {}
export const _MatAutocompleteMixinBase = mixinDisableRipple(MatAutocompleteBase);

/** Default `mat-autocomplete` options that can be overridden. */
export interface MatAutocompleteDefaultOptions {
/** Whether the first option should be highlighted when an autocomplete panel is opened. */
autoActiveFirstOption?: boolean;
}

/** Injection token to be used to override the default options for `mat-autocomplete`. */
export const MAT_AUTOCOMPLETE_DEFAULT_OPTIONS =
new InjectionToken<MatAutocompleteDefaultOptions>('mat-autocomplete-default-options');


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

/**
* Whether the first option should be highlighted when the autocomplete panel is opened.
* Can be configured globally through the `MAT_AUTOCOMPLETE_DEFAULT_OPTIONS` token.
*/
@Input()
get autoActiveFirstOption(): boolean { return this._autoActiveFirstOption; }
set autoActiveFirstOption(value: boolean) {
this._autoActiveFirstOption = coerceBooleanProperty(value);
}
private _autoActiveFirstOption: boolean;


/** Event that is emitted whenever an option from the list is selected. */
@Output() readonly optionSelected: EventEmitter<MatAutocompleteSelectedEvent> =
new EventEmitter<MatAutocompleteSelectedEvent>();
Expand All @@ -118,8 +144,19 @@ export class MatAutocomplete extends _MatAutocompleteMixinBase implements AfterC
/** Unique ID to be used by autocomplete trigger's "aria-owns" property. */
id: string = `mat-autocomplete-${_uniqueAutocompleteIdCounter++}`;

constructor(private _changeDetectorRef: ChangeDetectorRef, private _elementRef: ElementRef) {
constructor(
private _changeDetectorRef: ChangeDetectorRef,
private _elementRef: ElementRef,

// @deletion-target Turn into required param in 6.0.0
@Optional() @Inject(MAT_AUTOCOMPLETE_DEFAULT_OPTIONS)
defaults?: MatAutocompleteDefaultOptions) {
super();

this._autoActiveFirstOption = defaults &&
typeof defaults.autoActiveFirstOption !== 'undefined' ?
defaults.autoActiveFirstOption :
false;
}

ngAfterContentInit() {
Expand Down