Skip to content

Commit

Permalink
feat(material/autocomplete): add the ability to auto-select the activ…
Browse files Browse the repository at this point in the history
…e option while navigating

Adds the `autoSelectActiveOption` input to `mat-autocomplete` which allows the
consumer to opt into the behavior where the autocomplete will assign the active
option value as the user is navigating through the list. The value is only propagated
to the model once the panel is closed.

There are a couple of UX differences when the new option is enabled:
1. If the user presses escape while there's a pending auto-selected option, the value
is reverted to the last text they typed before they started navigating.
2. If the user clicks away, tabs away or presses enter while there's a pending option,
it will be selected.

The aforementioned UX differences are based on the Google search autocomplete and
one of the examples from the W3C here:
https://www.w3.org/TR/wai-aria-practices-1.1/examples/combobox/aria1.1pattern/listbox-combo.html
  • Loading branch information
crisbeto committed Oct 9, 2020
1 parent 7d99c35 commit ba613eb
Show file tree
Hide file tree
Showing 5 changed files with 501 additions and 38 deletions.
206 changes: 206 additions & 0 deletions src/material-experimental/mdc-autocomplete/autocomplete.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2345,6 +2345,212 @@ describe('MDC-based MatAutocomplete', () => {

});

describe('automatically selecting the active option', () => {
let fixture: ComponentFixture<SimpleAutocomplete>;

beforeEach(() => {
fixture = createComponent(SimpleAutocomplete);
fixture.detectChanges();
fixture.componentInstance.trigger.autocomplete.autoSelectActiveOption = true;
});

it('should update the input value as the user is navigating, without changing the model ' +
'value or closing the panel', fakeAsync(() => {
const {trigger, stateCtrl, closedSpy} = fixture.componentInstance;
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');

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

expect(stateCtrl.value).toBeFalsy();
expect(input.value).toBeFalsy();
expect(trigger.panelOpen).toBe(true);
expect(closedSpy).not.toHaveBeenCalled();

dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW);
fixture.detectChanges();

expect(stateCtrl.value).toBeFalsy();
expect(input.value).toBe('Alabama');
expect(trigger.panelOpen).toBe(true);
expect(closedSpy).not.toHaveBeenCalled();

dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW);
fixture.detectChanges();

expect(stateCtrl.value).toBeFalsy();
expect(input.value).toBe('California');
expect(trigger.panelOpen).toBe(true);
expect(closedSpy).not.toHaveBeenCalled();
}));

it('should revert back to the last typed value if the user presses escape', fakeAsync(() => {
const {trigger, stateCtrl, closedSpy} = fixture.componentInstance;
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');

trigger.openPanel();
fixture.detectChanges();
zone.simulateZoneExit();
fixture.detectChanges();
typeInElement(input, 'al');
fixture.detectChanges();
tick();

expect(stateCtrl.value).toBe('al');
expect(input.value).toBe('al');
expect(trigger.panelOpen).toBe(true);
expect(closedSpy).not.toHaveBeenCalled();

dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW);
fixture.detectChanges();

expect(stateCtrl.value).toBe('al');
expect(input.value).toBe('Alabama');
expect(trigger.panelOpen).toBe(true);
expect(closedSpy).not.toHaveBeenCalled();

dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
fixture.detectChanges();

expect(stateCtrl.value).toBe('al');
expect(input.value).toBe('al');
expect(trigger.panelOpen).toBe(false);
expect(closedSpy).toHaveBeenCalledTimes(1);
}));

it('should clear the input if the user presses escape while there was a pending ' +
'auto selection and there is no previous value', fakeAsync(() => {
const {trigger, stateCtrl} = fixture.componentInstance;
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');

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

expect(stateCtrl.value).toBeFalsy();
expect(input.value).toBeFalsy();

dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW);
fixture.detectChanges();

expect(stateCtrl.value).toBeFalsy();
expect(input.value).toBe('Alabama');

dispatchKeyboardEvent(document.body, 'keydown', ESCAPE);
fixture.detectChanges();

expect(stateCtrl.value).toBeFalsy();
expect(input.value).toBeFalsy();
}));

it('should propagate the auto-selected value if the user clicks away', fakeAsync(() => {
const {trigger, stateCtrl} = fixture.componentInstance;
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');

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

expect(stateCtrl.value).toBeFalsy();
expect(input.value).toBeFalsy();

dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW);
fixture.detectChanges();

expect(stateCtrl.value).toBeFalsy();
expect(input.value).toBe('Alabama');

dispatchFakeEvent(document, 'click');
fixture.detectChanges();

expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'});
expect(input.value).toBe('Alabama');
}));

it('should propagate the auto-selected value if the user tabs away', fakeAsync(() => {
const {trigger, stateCtrl} = fixture.componentInstance;
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');

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

expect(stateCtrl.value).toBeFalsy();
expect(input.value).toBeFalsy();

dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW);
fixture.detectChanges();

expect(stateCtrl.value).toBeFalsy();
expect(input.value).toBe('Alabama');

dispatchKeyboardEvent(input, 'keydown', TAB);
fixture.detectChanges();

expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'});
expect(input.value).toBe('Alabama');
}));

it('should propagate the auto-selected value if the user presses enter on it', fakeAsync(() => {
const {trigger, stateCtrl} = fixture.componentInstance;
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');

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

expect(stateCtrl.value).toBeFalsy();
expect(input.value).toBeFalsy();

dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW);
fixture.detectChanges();

expect(stateCtrl.value).toBeFalsy();
expect(input.value).toBe('Alabama');

dispatchKeyboardEvent(input, 'keydown', ENTER);
fixture.detectChanges();

expect(stateCtrl.value).toEqual({code: 'AL', name: 'Alabama'});
expect(input.value).toBe('Alabama');
}));

it('should allow the user to click on an option different from the auto-selected one',
fakeAsync(() => {
const {trigger, stateCtrl} = fixture.componentInstance;
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');

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

expect(stateCtrl.value).toBeFalsy();
expect(input.value).toBeFalsy();

dispatchKeyboardEvent(input, 'keydown', DOWN_ARROW);
fixture.detectChanges();

expect(stateCtrl.value).toBeFalsy();
expect(input.value).toBe('Alabama');

const options =
overlayContainerElement.querySelectorAll('mat-option') as NodeListOf<HTMLElement>;
options[2].click();
fixture.detectChanges();

expect(stateCtrl.value).toEqual({code: 'FL', name: 'Florida'});
expect(input.value).toBe('Florida');
}));

});

it('should have correct width when opened', () => {
const widthFixture = createComponent(SimpleAutocomplete);
widthFixture.componentInstance.width = 300;
Expand Down
Loading

0 comments on commit ba613eb

Please sign in to comment.