Skip to content

Commit d90b796

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 9d719c5 commit d90b796

File tree

3 files changed

+102
-3
lines changed

3 files changed

+102
-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

@@ -1210,6 +1211,81 @@ describe('MdSelect', () => {
12101211
expect(select.getAttribute('tabindex')).toEqual('0');
12111212
});
12121213

1214+
it('should be able to select options via the arrow keys on a closed select', () => {
1215+
const formControl = fixture.componentInstance.control;
1216+
const options = fixture.componentInstance.options.toArray();
1217+
1218+
expect(formControl.value).toBeFalsy('Expected no initial value.');
1219+
1220+
dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
1221+
1222+
expect(options[0].selected).toBe(true, 'Expected first option to be selected.');
1223+
expect(formControl.value).toBe(options[0].value,
1224+
'Expected value from first option to have been set on the model.');
1225+
1226+
dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
1227+
dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
1228+
1229+
// Note that the third option is skipped, because it is disabled.
1230+
expect(options[3].selected).toBe(true, 'Expected fourth option to be selected.');
1231+
expect(formControl.value).toBe(options[3].value,
1232+
'Expected value from fourth option to have been set on the model.');
1233+
1234+
dispatchKeyboardEvent(select, 'keydown', UP_ARROW);
1235+
1236+
expect(options[1].selected).toBe(true, 'Expected second option to be selected.');
1237+
expect(formControl.value).toBe(options[1].value,
1238+
'Expected value from second option to have been set on the model.');
1239+
});
1240+
1241+
it('should do nothing if the key manager did not change the active item', () => {
1242+
const formControl = fixture.componentInstance.control;
1243+
1244+
expect(formControl.value).toBeNull('Expected form control value to be empty.');
1245+
expect(formControl.pristine).toBe(true, 'Expected form control to be clean.');
1246+
1247+
dispatchKeyboardEvent(select, 'keydown', 16); // Press a random key.
1248+
1249+
expect(formControl.value).toBeNull('Expected form control value to stay empty.');
1250+
expect(formControl.pristine).toBe(true, 'Expected form control to stay clean.');
1251+
});
1252+
1253+
it('should continue from the selected option when the value is set programmatically', () => {
1254+
const formControl = fixture.componentInstance.control;
1255+
1256+
formControl.setValue('eggs-5');
1257+
fixture.detectChanges();
1258+
1259+
dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
1260+
1261+
expect(formControl.value).toBe('pasta-6');
1262+
expect(fixture.componentInstance.options.toArray()[6].selected).toBe(true);
1263+
});
1264+
1265+
it('should not cycle through the options if the control is disabled', () => {
1266+
const formControl = fixture.componentInstance.control;
1267+
1268+
formControl.setValue('eggs-5');
1269+
formControl.disable();
1270+
dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
1271+
1272+
expect(formControl.value).toBe('eggs-5', 'Expected value to remain unchaged.');
1273+
});
1274+
1275+
it('should not wrap selection around after reaching the end of the options', () => {
1276+
const lastOption = fixture.componentInstance.options.last;
1277+
1278+
fixture.componentInstance.options.forEach(() => {
1279+
dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
1280+
});
1281+
1282+
expect(lastOption.selected).toBe(true, 'Expected last option to be selected.');
1283+
1284+
dispatchKeyboardEvent(select, 'keydown', DOWN_ARROW);
1285+
1286+
expect(lastOption.selected).toBe(true, 'Expected last option to stay selected.');
1287+
});
1288+
12131289
});
12141290

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

src/lib/select/select.ts

+21-1
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,24 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
460460
_handleKeydown(event: KeyboardEvent): void {
461461
if (event.keyCode === ENTER || event.keyCode === SPACE) {
462462
this.open();
463+
} else if (!this.disabled) {
464+
let prevActiveItem = this._keyManager.activeItem;
465+
466+
// Cycle though the select options even when the select is closed,
467+
// matching the behavior of the native select element.
468+
// TODO(crisbeto): native selects also cycle through the options with left/right arrows,
469+
// however the key manager only supports up/down at the moment.
470+
this._keyManager.onKeydown(event);
471+
472+
let currentActiveItem = this._keyManager.activeItem as MdOption;
473+
474+
if (this._multiple) {
475+
this.open();
476+
} else if (currentActiveItem !== prevActiveItem) {
477+
this._clearSelection();
478+
this._setSelectionByValue(currentActiveItem.value);
479+
this._propagateChanges();
480+
}
463481
}
464482
}
465483

@@ -539,11 +557,13 @@ export class MdSelect implements AfterContentInit, OnDestroy, OnInit, ControlVal
539557
* @returns Option that has the corresponding value.
540558
*/
541559
private _selectValue(value: any): MdOption {
542-
let correspondingOption = this.options.find(option => option.value === value);
560+
let optionsArray = this.options.toArray();
561+
let correspondingOption = optionsArray.find(option => option.value === value);
543562

544563
if (correspondingOption) {
545564
correspondingOption.select();
546565
this._selectionModel.select(correspondingOption);
566+
this._keyManager.setActiveItem(optionsArray.indexOf(correspondingOption));
547567
}
548568

549569
return correspondingOption;

0 commit comments

Comments
 (0)