Skip to content

Commit 73efa57

Browse files
committed
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.
1 parent 59c6289 commit 73efa57

File tree

3 files changed

+100
-3
lines changed

3 files changed

+100
-3
lines changed

src/lib/core/a11y/focus-key-manager.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ export class FocusKeyManager extends ListKeyManager<Focusable> {
2323
*/
2424
setActiveItem(index: number): void {
2525
super.setActiveItem(index);
26-
this.activeItem.focus();
26+
27+
if (this.activeItem) {
28+
this.activeItem.focus();
29+
}
2730
}
2831

2932
}

src/lib/select/select.spec.ts

+77-1
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,12 @@ import {MdSelect, MdSelectFloatPlaceholderType} from './select';
1616
import {MdSelectDynamicMultipleError, MdSelectNonArrayValueError} from './select-errors';
1717
import {MdOption} from '../core/option/option';
1818
import {Dir} from '../core/rtl/dir';
19+
import {DOWN_ARROW, UP_ARROW} from '../core/keyboard/keycodes';
1920
import {
2021
ControlValueAccessor, FormControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule
2122
} from '@angular/forms';
2223
import {ViewportRuler} from '../core/overlay/position/viewport-ruler';
23-
import {dispatchFakeEvent} from '../core/testing/dispatch-events';
24+
import {dispatchFakeEvent, dispatchKeyboardEvent} from '../core/testing/dispatch-events';
2425
import {wrappedErrorMessage} from '../core/testing/wrapped-error-message';
2526

2627

@@ -1170,6 +1171,81 @@ describe('MdSelect', () => {
11701171
expect(select.getAttribute('tabindex')).toEqual('0');
11711172
});
11721173

1174+
it('should be able to select options via the arrow keys on a closed select', () => {
1175+
const formControl = fixture.componentInstance.control;
1176+
const options = fixture.componentInstance.options.toArray();
1177+
1178+
expect(formControl.value).toBeFalsy('Expected no initial value.');
1179+
1180+
dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
1181+
1182+
expect(options[0].selected).toBe(true, 'Expected first option to be selected.');
1183+
expect(formControl.value).toBe(options[0].value,
1184+
'Expected value from first option to have been set on the model.');
1185+
1186+
dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
1187+
dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
1188+
1189+
// Note that the third option is skipped, because it is disabled.
1190+
expect(options[3].selected).toBe(true, 'Expected fourth option to be selected.');
1191+
expect(formControl.value).toBe(options[3].value,
1192+
'Expected value from fourth option to have been set on the model.');
1193+
1194+
dispatchKeyboardEvent(select, 'keydown', UP_ARROW);
1195+
1196+
expect(options[1].selected).toBe(true, 'Expected second option to be selected.');
1197+
expect(formControl.value).toBe(options[1].value,
1198+
'Expected value from second option to have been set on the model.');
1199+
});
1200+
1201+
it('should do nothing if the key manager did not change the active item', () => {
1202+
const formControl = fixture.componentInstance.control;
1203+
1204+
expect(formControl.value).toBeNull('Expected form control value to be empty.');
1205+
expect(formControl.pristine).toBe(true, 'Expected form control to be clean.');
1206+
1207+
dispatchKeyboardEvent(select, 'keydown', 16); // Press a random key.
1208+
1209+
expect(formControl.value).toBeNull('Expected form control value to stay empty.');
1210+
expect(formControl.pristine).toBe(true, 'Expected form control to stay clean.');
1211+
});
1212+
1213+
it('should continue from the selected option when the value is set programmatically', () => {
1214+
const formControl = fixture.componentInstance.control;
1215+
1216+
formControl.setValue('eggs-5');
1217+
fixture.detectChanges();
1218+
1219+
dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
1220+
1221+
expect(formControl.value).toBe('pasta-6');
1222+
expect(fixture.componentInstance.options.toArray()[6].selected).toBe(true);
1223+
});
1224+
1225+
it('should not cycle through the options if the control is disabled', () => {
1226+
const formControl = fixture.componentInstance.control;
1227+
1228+
formControl.setValue('eggs-5');
1229+
formControl.disable();
1230+
dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
1231+
1232+
expect(formControl.value).toBe('eggs-5', 'Expected value to remain unchaged.');
1233+
});
1234+
1235+
it('should not wrap selection around after reaching the end of the options', () => {
1236+
const lastOption = fixture.componentInstance.options.last;
1237+
1238+
fixture.componentInstance.options.forEach(() => {
1239+
dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
1240+
});
1241+
1242+
expect(lastOption.selected).toBe(true, 'Expected last option to be selected.');
1243+
1244+
dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
1245+
1246+
expect(lastOption.selected).toBe(true, 'Expected last option to stay selected.');
1247+
});
1248+
11731249
});
11741250

11751251
describe('for options', () => {

src/lib/select/select.ts

+19-1
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,8 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
443443
return this._dir ? this._dir.value === 'rtl' : false;
444444
}
445445

446-
/** The width of the trigger element. This is necessary to match
446+
/**
447+
* The width of the trigger element. This is necessary to match
447448
* the overlay width to the trigger width.
448449
*/
449450
_getWidth(): number {
@@ -454,6 +455,22 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
454455
_handleKeydown(event: KeyboardEvent): void {
455456
if (event.keyCode === ENTER || event.keyCode === SPACE) {
456457
this.open();
458+
} else if (!this.disabled) {
459+
let prevActiveItem = this._keyManager.activeItem;
460+
461+
// TODO(crisbeto): native selects also cycle through the options with left/right arrows,
462+
// however the key manager only supports up/down at the moment.
463+
this._keyManager.onKeydown(event);
464+
465+
let currentActiveItem = this._keyManager.activeItem as MdOption;
466+
467+
if (this._multiple) {
468+
this.open();
469+
} else if (currentActiveItem !== prevActiveItem) {
470+
this._clearSelection();
471+
this._setSelectionByValue(currentActiveItem.value);
472+
this._propagateChanges();
473+
}
457474
}
458475
}
459476

@@ -538,6 +555,7 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
538555
if (correspondingOption) {
539556
correspondingOption.select();
540557
this._selectionModel.select(correspondingOption);
558+
this._keyManager.setActiveItem(this.options.toArray().indexOf(correspondingOption));
541559
}
542560

543561
return correspondingOption;

0 commit comments

Comments
 (0)