Skip to content

Commit

Permalink
feat(autocomplete): add the ability to highlight the first option on …
Browse files Browse the repository at this point in the history
…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 angular#8423.
  • Loading branch information
crisbeto committed Jan 20, 2018
1 parent 4523556 commit e2ff050
Show file tree
Hide file tree
Showing 3 changed files with 88 additions and 13 deletions.
7 changes: 5 additions & 2 deletions src/lib/autocomplete/autocomplete-trigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,9 +508,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.highlightFirstOption ? 0 : -1);
}

}
56 changes: 46 additions & 10 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 @@ -397,9 +399,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 @@ -1259,12 +1263,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 @@ -1288,6 +1292,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 @@ -1309,6 +1316,35 @@ describe('MatAutocomplete', () => {
expect(componentOptions[0].deselect).toHaveBeenCalled();
componentOptions.slice(1).forEach(option => expect(option.deselect).not.toHaveBeenCalled());
}));

it('should be able to preselect the first option', fakeAsync(() => {
fixture.componentInstance.trigger.autocomplete.highlightFirstOption = true;
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 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: {highlightFirstOption: 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.');
}));
});

describe('panel closing', () => {
Expand Down Expand Up @@ -1672,8 +1708,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
38 changes: 37 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. */
highlightFirstOption?: 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 highlightFirstOption(): boolean { return this._highlightFirstOption; }
set highlightFirstOption(value: boolean) {
this._highlightFirstOption = coerceBooleanProperty(value);
}
private _highlightFirstOption: boolean;


/** Event that is emitted whenever an option from the list is selected. */
@Output() optionSelected: EventEmitter<MatAutocompleteSelectedEvent> =
new EventEmitter<MatAutocompleteSelectedEvent>();
Expand All @@ -118,8 +144,18 @@ 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._highlightFirstOption = defaults && typeof defaults.highlightFirstOption !== 'undefined' ?
defaults.highlightFirstOption :
false;
}

ngAfterContentInit() {
Expand Down

0 comments on commit e2ff050

Please sign in to comment.