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 1577f252e85c..9d20d14e104b 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'; @@ -1170,6 +1171,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 91b1b9a47558..195b183f4442 100644 --- a/src/lib/select/select.ts +++ b/src/lib/select/select.ts @@ -443,7 +443,8 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal return this._dir ? this._dir.value === 'rtl' : false; } - /** The width of the trigger element. This is necessary to match + /** + * The width of the trigger element. This is necessary to match * the overlay width to the trigger width. */ _getWidth(): number { @@ -454,6 +455,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(); + } } } @@ -533,11 +552,14 @@ 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 correspondingOptionIndex = optionsArray.findIndex(option => option.value === value); + let correspondingOption = optionsArray[correspondingOptionIndex]; if (correspondingOption) { correspondingOption.select(); this._selectionModel.select(correspondingOption); + this._keyManager.setActiveItem(correspondingOptionIndex); } return correspondingOption;