From d90b796062a8a2689418f4ba964fb0b62113b950 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Tue, 4 Apr 2017 22:27:46 -0600 Subject: [PATCH] feat(select): add ability to cycle through options with arrow keys when closed * Adds the ability for users to select options by focusing on a closed `md-select` and pressing the up/down arrow keys. * Fixes a bug that prevents the selection from going to the first item in a `ListKeyManager`, if there were no previously-selected items. * Adds an extra null check to the `FocusKeyManager` to avoid issues where the focused item is cleared. Fixes #2990. --- src/lib/core/a11y/focus-key-manager.ts | 5 +- src/lib/select/select.spec.ts | 78 +++++++++++++++++++++++++- src/lib/select/select.ts | 22 +++++++- 3 files changed, 102 insertions(+), 3 deletions(-) diff --git a/src/lib/core/a11y/focus-key-manager.ts b/src/lib/core/a11y/focus-key-manager.ts index d92160b07c15..dbf1b6107b59 100644 --- a/src/lib/core/a11y/focus-key-manager.ts +++ b/src/lib/core/a11y/focus-key-manager.ts @@ -23,7 +23,10 @@ export class FocusKeyManager extends ListKeyManager { */ setActiveItem(index: number): void { super.setActiveItem(index); - this.activeItem.focus(); + + if (this.activeItem) { + this.activeItem.focus(); + } } } diff --git a/src/lib/select/select.spec.ts b/src/lib/select/select.spec.ts index b53781ede656..5591eea6464c 100644 --- a/src/lib/select/select.spec.ts +++ b/src/lib/select/select.spec.ts @@ -16,11 +16,12 @@ import {MdSelect, MdSelectFloatPlaceholderType} from './select'; import {MdSelectDynamicMultipleError, MdSelectNonArrayValueError} from './select-errors'; import {MdOption} from '../core/option/option'; import {Dir} from '../core/rtl/dir'; +import {DOWN_ARROW, UP_ARROW} from '../core/keyboard/keycodes'; import { ControlValueAccessor, FormControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule } from '@angular/forms'; import {ViewportRuler} from '../core/overlay/position/viewport-ruler'; -import {dispatchFakeEvent} from '../core/testing/dispatch-events'; +import {dispatchFakeEvent, dispatchKeyboardEvent} from '../core/testing/dispatch-events'; import {wrappedErrorMessage} from '../core/testing/wrapped-error-message'; @@ -1210,6 +1211,81 @@ describe('MdSelect', () => { expect(select.getAttribute('tabindex')).toEqual('0'); }); + it('should be able to select options via the arrow keys on a closed select', () => { + const formControl = fixture.componentInstance.control; + const options = fixture.componentInstance.options.toArray(); + + expect(formControl.value).toBeFalsy('Expected no initial value.'); + + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + expect(options[0].selected).toBe(true, 'Expected first option to be selected.'); + expect(formControl.value).toBe(options[0].value, + 'Expected value from first option to have been set on the model.'); + + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + // Note that the third option is skipped, because it is disabled. + expect(options[3].selected).toBe(true, 'Expected fourth option to be selected.'); + expect(formControl.value).toBe(options[3].value, + 'Expected value from fourth option to have been set on the model.'); + + dispatchKeyboardEvent(select, 'keydown', UP_ARROW); + + expect(options[1].selected).toBe(true, 'Expected second option to be selected.'); + expect(formControl.value).toBe(options[1].value, + 'Expected value from second option to have been set on the model.'); + }); + + it('should do nothing if the key manager did not change the active item', () => { + const formControl = fixture.componentInstance.control; + + expect(formControl.value).toBeNull('Expected form control value to be empty.'); + expect(formControl.pristine).toBe(true, 'Expected form control to be clean.'); + + dispatchKeyboardEvent(select, 'keydown', 16); // Press a random key. + + expect(formControl.value).toBeNull('Expected form control value to stay empty.'); + expect(formControl.pristine).toBe(true, 'Expected form control to stay clean.'); + }); + + it('should continue from the selected option when the value is set programmatically', () => { + const formControl = fixture.componentInstance.control; + + formControl.setValue('eggs-5'); + fixture.detectChanges(); + + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + expect(formControl.value).toBe('pasta-6'); + expect(fixture.componentInstance.options.toArray()[6].selected).toBe(true); + }); + + it('should not cycle through the options if the control is disabled', () => { + const formControl = fixture.componentInstance.control; + + formControl.setValue('eggs-5'); + formControl.disable(); + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + expect(formControl.value).toBe('eggs-5', 'Expected value to remain unchaged.'); + }); + + it('should not wrap selection around after reaching the end of the options', () => { + const lastOption = fixture.componentInstance.options.last; + + fixture.componentInstance.options.forEach(() => { + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + }); + + expect(lastOption.selected).toBe(true, 'Expected last option to be selected.'); + + dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW); + + expect(lastOption.selected).toBe(true, 'Expected last option to stay selected.'); + }); + }); describe('for options', () => { diff --git a/src/lib/select/select.ts b/src/lib/select/select.ts index a77488dc73bd..105c9762cca3 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -460,6 +460,24 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal _handleKeydown(event: KeyboardEvent): void { if (event.keyCode === ENTER || event.keyCode === SPACE) { this.open(); + } else if (!this.disabled) { + let prevActiveItem = this._keyManager.activeItem; + + // Cycle though the select options even when the select is closed, + // matching the behavior of the native select element. + // TODO(crisbeto): native selects also cycle through the options with left/right arrows, + // however the key manager only supports up/down at the moment. + this._keyManager.onKeydown(event); + + let currentActiveItem = this._keyManager.activeItem as MdOption; + + if (this._multiple) { + this.open(); + } else if (currentActiveItem !== prevActiveItem) { + this._clearSelection(); + this._setSelectionByValue(currentActiveItem.value); + this._propagateChanges(); + } } } @@ -539,11 +557,13 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal * @returns Option that has the corresponding value. */ private _selectValue(value: any): MdOption { - let correspondingOption = this.options.find(option => option.value === value); + let optionsArray = this.options.toArray(); + let correspondingOption = optionsArray.find(option => option.value === value); if (correspondingOption) { correspondingOption.select(); this._selectionModel.select(correspondingOption); + this._keyManager.setActiveItem(optionsArray.indexOf(correspondingOption)); } return correspondingOption;