Skip to content

Commit 7ef1e47

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 c203589 commit 7ef1e47

File tree

5 files changed

+130
-6
lines changed

5 files changed

+130
-6
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/core/a11y/list-key-manager.spec.ts

+10
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,16 @@ describe('Key managers', () => {
240240
expect(TAB_EVENT.defaultPrevented).toBe(false);
241241
});
242242

243+
it('it should activate the first item when pressing down on a clean key manager', () => {
244+
keyManager = new ListKeyManager<FakeFocusable>(itemList);
245+
246+
expect(keyManager.activeItemIndex).toBeNull('Expected active index to default to null.');
247+
248+
keyManager.onKeydown(DOWN_ARROW_EVENT);
249+
250+
expect(keyManager.activeItemIndex).toBe(0, 'Expected first item to become active.');
251+
});
252+
243253
});
244254

245255
describe('programmatic focus', () => {

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

+1-1
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export interface CanDisable {
1616
* of items, it will set the active item correctly when arrow events occur.
1717
*/
1818
export class ListKeyManager<T extends CanDisable> {
19-
private _activeItemIndex: number;
19+
private _activeItemIndex: number = null;
2020
private _activeItem: T;
2121
private _tabOut: Subject<any> = new Subject();
2222
private _wrap: boolean = false;

src/lib/select/select.spec.ts

+91
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {OverlayContainer} from '../core/overlay/overlay-container';
1414
import {MdSelect, MdSelectFloatPlaceholderType} from './select';
1515
import {MdOption} from '../core/option/option';
1616
import {Dir} from '../core/rtl/dir';
17+
import {DOWN_ARROW, UP_ARROW} from '../core/keyboard/keycodes';
1718
import {
1819
ControlValueAccessor, FormControl, FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule
1920
} from '@angular/forms';
@@ -1078,6 +1079,79 @@ describe('MdSelect', () => {
10781079
expect(select.getAttribute('tabindex')).toEqual('0');
10791080
});
10801081

1082+
it('should be able to select options via the arrow keys on a closed select', () => {
1083+
const formControl = fixture.componentInstance.control;
1084+
const options = fixture.componentInstance.options.toArray();
1085+
1086+
expect(formControl.value).toBeFalsy('Expected no initial value.');
1087+
1088+
dispatchKeydownEvent(select, DOWN_ARROW);
1089+
1090+
expect(options[0].selected).toBe(true, 'Expected first option to be selected.');
1091+
expect(formControl.value).toBe(options[0].value,
1092+
'Expected value from first option to have been set on the model.');
1093+
1094+
dispatchKeydownEvent(select, DOWN_ARROW);
1095+
dispatchKeydownEvent(select, DOWN_ARROW);
1096+
1097+
// Note that the third option is skipped, because it is disabled.
1098+
expect(options[3].selected).toBe(true, 'Expected fourth option to be selected.');
1099+
expect(formControl.value).toBe(options[3].value,
1100+
'Expected value from fourth option to have been set on the model.');
1101+
1102+
dispatchKeydownEvent(select, UP_ARROW);
1103+
1104+
expect(options[1].selected).toBe(true, 'Expected second option to be selected.');
1105+
expect(formControl.value).toBe(options[1].value,
1106+
'Expected value from second option to have been set on the model.');
1107+
});
1108+
1109+
it('should do nothing if the key manager did not change the active item', () => {
1110+
const formControl = fixture.componentInstance.control;
1111+
1112+
expect(formControl.value).toBeNull('Expected form control value to be empty.');
1113+
expect(formControl.pristine).toBe(true, 'Expected form control to be clean.');
1114+
1115+
dispatchKeydownEvent(select, 16); // Press a random key. In this case left shift.
1116+
1117+
expect(formControl.value).toBeNull('Expected form control value to stay empty.');
1118+
expect(formControl.pristine).toBe(true, 'Expected form control to stay clean.');
1119+
});
1120+
1121+
it('should continue from the selected option when the value is set programmatically', () => {
1122+
const formControl = fixture.componentInstance.control;
1123+
1124+
formControl.setValue('eggs-5');
1125+
fixture.detectChanges();
1126+
1127+
dispatchKeydownEvent(select, DOWN_ARROW);
1128+
1129+
expect(formControl.value).toBe('pasta-6');
1130+
expect(fixture.componentInstance.options.toArray()[6].selected).toBe(true);
1131+
});
1132+
1133+
it('should not cycle through the options if the control is disabled', () => {
1134+
const formControl = fixture.componentInstance.control;
1135+
1136+
formControl.setValue('eggs-5');
1137+
formControl.disable();
1138+
dispatchKeydownEvent(select, DOWN_ARROW);
1139+
1140+
expect(formControl.value).toBe('eggs-5', 'Expected value to remain unchaged.');
1141+
});
1142+
1143+
it('should not wrap selection around after reaching the end of the options', () => {
1144+
const lastOption = fixture.componentInstance.options.last;
1145+
1146+
fixture.componentInstance.options.forEach(() => dispatchKeydownEvent(select, DOWN_ARROW));
1147+
1148+
expect(lastOption.selected).toBe(true, 'Expected last option to be selected.');
1149+
1150+
dispatchKeydownEvent(select, DOWN_ARROW);
1151+
1152+
expect(lastOption.selected).toBe(true, 'Expected last option to stay selected.');
1153+
});
1154+
10811155
});
10821156

10831157
describe('for options', () => {
@@ -1603,6 +1677,23 @@ function dispatchEvent(eventName: string, element: HTMLElement): void {
16031677
element.dispatchEvent(event);
16041678
}
16051679

1680+
/**
1681+
* TODO: Move this to core testing utility.
1682+
*
1683+
* Dispatches a keydown event on an element.
1684+
* @param element Element on which to dispatch the event.
1685+
* @param keyCode Code of the pressed key.
1686+
*/
1687+
function dispatchKeydownEvent(element: Node, keyCode: number) {
1688+
let event: any = document.createEvent('KeyboardEvent');
1689+
(event.initKeyEvent || event.initKeyboardEvent).bind(event)(
1690+
'keydown', true, true, window, 0, 0, 0, 0, 0, keyCode);
1691+
Object.defineProperty(event, 'keyCode', {
1692+
get: function() { return keyCode; }
1693+
});
1694+
element.dispatchEvent(event);
1695+
}
1696+
16061697
class FakeViewportRuler {
16071698
getViewportRect() {
16081699
return {

src/lib/select/select.ts

+24-4
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,8 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
363363
return this._dir ? this._dir.value === 'rtl' : false;
364364
}
365365

366-
/** The width of the trigger element. This is necessary to match
366+
/**
367+
* The width of the trigger element. This is necessary to match
367368
* the overlay width to the trigger width.
368369
*/
369370
_getWidth(): number {
@@ -374,6 +375,21 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
374375
_handleKeydown(event: KeyboardEvent): void {
375376
if (event.keyCode === ENTER || event.keyCode === SPACE) {
376377
this.open();
378+
} else if (!this.disabled) {
379+
let prevActiveItem = this._keyManager.activeItem;
380+
381+
// TODO(crisbeto): native selects also cycle through the options with left/right arrows,
382+
// however the key manager only supports up/down at the moment.
383+
this._keyManager.onKeydown(event);
384+
385+
let currentActiveItem = this._keyManager.activeItem as MdOption;
386+
387+
// TODO(crisbeto): once #2722 gets in, this should open
388+
// the panel instead of selecting in `multiple` mode.
389+
if (currentActiveItem !== prevActiveItem) {
390+
this._emitChangeEvent(currentActiveItem);
391+
currentActiveItem.select();
392+
}
377393
}
378394
}
379395

@@ -435,6 +451,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
435451
for (let i = 0; i < this.options.length; i++) {
436452
if (options[i].value === value) {
437453
options[i].select();
454+
this._keyManager.setActiveItem(i);
438455
return;
439456
}
440457
}
@@ -447,6 +464,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
447464
private _clearSelection(): void {
448465
this._selected = null;
449466
this._updateOptions();
467+
this._keyManager.setActiveItem(null);
450468
}
451469

452470
private _getTriggerRect(): ClientRect {
@@ -472,7 +490,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
472490
private _listenToOptions(): void {
473491
this.options.forEach((option: MdOption) => {
474492
const sub = option.onSelect.subscribe((event: MdOptionSelectEvent) => {
475-
if (event.isUserInput && this._selected !== option) {
493+
if (event.isUserInput) {
476494
this._emitChangeEvent(option);
477495
}
478496
this._onSelect(option);
@@ -489,8 +507,10 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
489507

490508
/** Emits an event when the user selects an option. */
491509
private _emitChangeEvent(option: MdOption): void {
492-
this._onChange(option.value);
493-
this.change.emit(new MdSelectChange(this, option.value));
510+
if (this._selected !== option) {
511+
this._onChange(option.value);
512+
this.change.emit(new MdSelectChange(this, option.value));
513+
}
494514
}
495515

496516
/** Records option IDs to pass to the aria-owns property. */

0 commit comments

Comments
 (0)