Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(angular/autocomplete): add the ability to auto-select the active…
Browse files Browse the repository at this point in the history
… option while navigating

Adds the `autoSelectActiveOption` input to `sbb-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
jeripeierSBB committed Feb 28, 2022
1 parent 0e72135 commit daf068d
Showing 3 changed files with 287 additions and 32 deletions.
96 changes: 65 additions & 31 deletions src/angular/autocomplete/autocomplete-trigger.ts
Original file line number Diff line number Diff line change
@@ -151,6 +151,15 @@ export class SbbAutocompleteTrigger
*/
private _canOpenOnNextFocus = true;

/** Value inside the input before we auto-selected an option. */
private _valueBeforeAutoSelection: string | undefined;

/**
* Current option that we have auto-selected as the user is navigating,
* but which hasn't been propagated to the model value yet.
*/
private _pendingAutoselectedOption: SbbOption | null;

/** Stream of keyboard events that can close the panel. */
private readonly _closeKeyEventStream = new Subject<void>();

@@ -329,6 +338,7 @@ export class SbbAutocompleteTrigger
}

this.autocomplete._isOpen = this._overlayAttached = false;
this._pendingAutoselectedOption = null;

if (this._overlayRef && this._overlayRef.hasAttached()) {
this._overlayRef.detach();
@@ -438,7 +448,7 @@ export class SbbAutocompleteTrigger

// Implemented as part of ControlValueAccessor.
writeValue(value: any): void {
Promise.resolve().then(() => this._setTriggerValue(value));
Promise.resolve(null).then(() => this._assignOptionValue(value));
}

// Implemented as part of ControlValueAccessor.
@@ -486,6 +496,15 @@ export class SbbAutocompleteTrigger

if (isArrowKey || this.autocomplete._keyManager.activeItem !== prevActiveItem) {
this._scrollToOption(this.autocomplete._keyManager.activeItemIndex || 0);

if (this.autocomplete.autoSelectActiveOption && this.activeOption) {
if (!this._pendingAutoselectedOption) {
this._valueBeforeAutoSelection = this._element.nativeElement.value;
}

this._pendingAutoselectedOption = this.activeOption;
this._assignOptionValue(this.activeOption.value);
}
}
}
}
@@ -508,6 +527,7 @@ export class SbbAutocompleteTrigger
// See: https://connect.microsoft.com/IE/feedback/details/885747/
if (this._previousValue !== value) {
this._previousValue = value;
this._pendingAutoselectedOption = null;
this._onChange(value);
this._inputValue.next(target.value);

@@ -593,26 +613,28 @@ export class SbbAutocompleteTrigger
}
}

private _setTriggerValue(value: any): void {
private _assignOptionValue(value: any): void {
const toDisplay =
this.autocomplete && this.autocomplete.displayWith
? this.autocomplete.displayWith(value)
: value;

// Simply falling back to an empty string if the display value is falsy does not work properly.
// The display value can also be the number zero and shouldn't fall back to an empty string.
const inputValue = toDisplay != null ? toDisplay : '';
this._updateNativeInputValue(toDisplay != null ? toDisplay : '');
}

private _updateNativeInputValue(value: string): void {
// If it's used within a `SbbFormField`, we should set it through the property so it can go
// through change detection.
if (this._formField && this._formField._control) {
this._formField._control.value = inputValue;
this._formField._control.value = value;
} else {
this._element.nativeElement.value = inputValue;
this._element.nativeElement.value = value;
}

this._previousValue = inputValue;
this._inputValue.next(inputValue);
this._previousValue = value;
this._inputValue.next(value);
}

/**
@@ -621,13 +643,13 @@ export class SbbAutocompleteTrigger
* stemmed from the user.
*/
private _setValueAndClose(event: SbbOptionSelectionChange | null): void {
const source = event && event.source;
const toSelect = event ? event.source : this._pendingAutoselectedOption;

if (source) {
this._clearPreviousSelectedOption(source);
this._setTriggerValue(source.value);
this._onChange(source.value);
this.autocomplete._emitSelectEvent(source);
if (toSelect) {
this._clearPreviousSelectedOption(toSelect);
this._assignOptionValue(toSelect.value);
this._onChange(toSelect.value);
this.autocomplete._emitSelectEvent(toSelect);
this._element.nativeElement.focus();
}

@@ -681,24 +703,7 @@ export class SbbAutocompleteTrigger
});
}

// Use the `keydownEvents` in order to take advantage of
// the overlay event targeting provided by the CDK overlay.
overlayRef.keydownEvents().subscribe((event) => {
// Close when pressing ESCAPE or ALT + UP_ARROW, based on the a11y guidelines.
// See: https://www.w3.org/TR/wai-aria-practices-1.1/#textbox-keyboard-interaction
if (
(event.keyCode === ESCAPE && !hasModifierKey(event)) ||
(event.keyCode === UP_ARROW && hasModifierKey(event, 'altKey'))
) {
this._closeKeyEventStream.next();
this._resetActiveItem();

// We need to stop propagation, otherwise the event will eventually
// reach the input itself and cause the overlay to be reopened.
event.stopPropagation();
event.preventDefault();
}
});
this._handleOverlayEvents(overlayRef);

this._viewportSubscription = this._viewportRuler.change().subscribe(() => {
if (this.panelOpen && overlayRef) {
@@ -862,4 +867,33 @@ export class SbbAutocompleteTrigger
}
}
}

/** Handles keyboard events coming from the overlay panel. */
private _handleOverlayEvents(overlayRef: OverlayRef) {
// Use the `keydownEvents` in order to take advantage of
// the overlay event targeting provided by the CDK overlay.
overlayRef.keydownEvents().subscribe((event) => {
// Close when pressing ESCAPE or ALT + UP_ARROW, based on the a11y guidelines.
// See: https://www.w3.org/TR/wai-aria-practices-1.1/#textbox-keyboard-interaction
if (
(event.keyCode === ESCAPE && !hasModifierKey(event)) ||
(event.keyCode === UP_ARROW && hasModifierKey(event, 'altKey'))
) {
// If the user had typed something in before we autoselected an option, and they decided
// to cancel the selection, restore the input value to the one they had typed in.
if (this._pendingAutoselectedOption) {
this._updateNativeInputValue(this._valueBeforeAutoSelection ?? '');
this._pendingAutoselectedOption = null;
}

this._closeKeyEventStream.next();
this._resetActiveItem();

// We need to stop propagation, otherwise the event will eventually
// reach the input itself and cause the overlay to be reopened.
event.stopPropagation();
event.preventDefault();
}
});
}
}
207 changes: 207 additions & 0 deletions src/angular/autocomplete/autocomplete.spec.ts
Original file line number Diff line number Diff line change
@@ -3137,6 +3137,213 @@ describe('SbbAutocomplete', () => {
expect(spy).not.toHaveBeenCalled();
}));

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, numberCtrl, closedSpy } = fixture.componentInstance;
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');

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

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

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

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

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

expect(numberCtrl.value).toBeFalsy();
expect(input.value).toBe('Zwei');
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, numberCtrl, closedSpy } = fixture.componentInstance;
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');

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

expect(numberCtrl.value).toBe('ei');
expect(input.value).toBe('ei');
expect(trigger.panelOpen).toBe(true);
expect(closedSpy).not.toHaveBeenCalled();

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

expect(numberCtrl.value).toBe('ei');
expect(input.value).toBe('Eins');
expect(trigger.panelOpen).toBe(true);
expect(closedSpy).not.toHaveBeenCalled();

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

expect(numberCtrl.value).toBe('ei');
expect(input.value).toBe('ei');
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, numberCtrl } = fixture.componentInstance;
const input: HTMLInputElement = fixture.nativeElement.querySelector('input');

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

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

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

expect(numberCtrl.value).toBeFalsy();
expect(input.value).toBe('Eins');

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

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

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

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

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

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

expect(numberCtrl.value).toBeFalsy();
expect(input.value).toBe('Eins');

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

expect(numberCtrl.value.name).toEqual('Eins');
expect(input.value).toBe('Eins');
}));

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

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

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

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

expect(numberCtrl.value).toBeFalsy();
expect(input.value).toBe('Eins');

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

expect(numberCtrl.value.name).toEqual('Eins');
expect(input.value).toBe('Eins');
}));

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

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

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

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

expect(numberCtrl.value).toBeFalsy();
expect(input.value).toBe('Eins');

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

expect(numberCtrl.value.name).toEqual('Eins');
expect(input.value).toBe('Eins');
}));

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

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

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

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

expect(numberCtrl.value).toBeFalsy();
expect(input.value).toBe('Eins');

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

expect(numberCtrl.value.name).toEqual('Drei');
expect(input.value).toBe('Drei');
}));
});

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

0 comments on commit daf068d

Please sign in to comment.