Skip to content

Commit

Permalink
feat(select): add ability to cycle through options with arrow keys wh…
Browse files Browse the repository at this point in the history
…en 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 angular#2990.
  • Loading branch information
crisbeto committed Feb 26, 2017
1 parent c203589 commit 7ef1e47
Show file tree
Hide file tree
Showing 5 changed files with 130 additions and 6 deletions.
5 changes: 4 additions & 1 deletion src/lib/core/a11y/focus-key-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ export class FocusKeyManager extends ListKeyManager<Focusable> {
*/
setActiveItem(index: number): void {
super.setActiveItem(index);
this.activeItem.focus();

if (this.activeItem) {
this.activeItem.focus();
}
}

}
10 changes: 10 additions & 0 deletions src/lib/core/a11y/list-key-manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,16 @@ describe('Key managers', () => {
expect(TAB_EVENT.defaultPrevented).toBe(false);
});

it('it should activate the first item when pressing down on a clean key manager', () => {
keyManager = new ListKeyManager<FakeFocusable>(itemList);

expect(keyManager.activeItemIndex).toBeNull('Expected active index to default to null.');

keyManager.onKeydown(DOWN_ARROW_EVENT);

expect(keyManager.activeItemIndex).toBe(0, 'Expected first item to become active.');
});

});

describe('programmatic focus', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/lib/core/a11y/list-key-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export interface CanDisable {
* of items, it will set the active item correctly when arrow events occur.
*/
export class ListKeyManager<T extends CanDisable> {
private _activeItemIndex: number;
private _activeItemIndex: number = null;
private _activeItem: T;
private _tabOut: Subject<any> = new Subject();
private _wrap: boolean = false;
Expand Down
91 changes: 91 additions & 0 deletions src/lib/select/select.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {OverlayContainer} from '../core/overlay/overlay-container';
import {MdSelect, MdSelectFloatPlaceholderType} from './select';
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';
Expand Down Expand Up @@ -1078,6 +1079,79 @@ 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.');

dispatchKeydownEvent(select, 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.');

dispatchKeydownEvent(select, DOWN_ARROW);
dispatchKeydownEvent(select, 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.');

dispatchKeydownEvent(select, 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.');

dispatchKeydownEvent(select, 16); // Press a random key. In this case left shift.

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();

dispatchKeydownEvent(select, 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();
dispatchKeydownEvent(select, 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(() => dispatchKeydownEvent(select, DOWN_ARROW));

expect(lastOption.selected).toBe(true, 'Expected last option to be selected.');

dispatchKeydownEvent(select, DOWN_ARROW);

expect(lastOption.selected).toBe(true, 'Expected last option to stay selected.');
});

});

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

/**
* TODO: Move this to core testing utility.
*
* Dispatches a keydown event on an element.
* @param element Element on which to dispatch the event.
* @param keyCode Code of the pressed key.
*/
function dispatchKeydownEvent(element: Node, keyCode: number) {
let event: any = document.createEvent('KeyboardEvent');
(event.initKeyEvent || event.initKeyboardEvent).bind(event)(
'keydown', true, true, window, 0, 0, 0, 0, 0, keyCode);
Object.defineProperty(event, 'keyCode', {
get: function() { return keyCode; }
});
element.dispatchEvent(event);
}

class FakeViewportRuler {
getViewportRect() {
return {
Expand Down
28 changes: 24 additions & 4 deletions src/lib/select/select.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,8 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
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 {
Expand All @@ -374,6 +375,21 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
_handleKeydown(event: KeyboardEvent): void {
if (event.keyCode === ENTER || event.keyCode === SPACE) {
this.open();
} else if (!this.disabled) {
let prevActiveItem = this._keyManager.activeItem;

// 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;

// TODO(crisbeto): once #2722 gets in, this should open
// the panel instead of selecting in `multiple` mode.
if (currentActiveItem !== prevActiveItem) {
this._emitChangeEvent(currentActiveItem);
currentActiveItem.select();
}
}
}

Expand Down Expand Up @@ -435,6 +451,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
for (let i = 0; i < this.options.length; i++) {
if (options[i].value === value) {
options[i].select();
this._keyManager.setActiveItem(i);
return;
}
}
Expand All @@ -447,6 +464,7 @@ export class MdSelect implements AfterContentInit, ControlValueAccessor, OnDestr
private _clearSelection(): void {
this._selected = null;
this._updateOptions();
this._keyManager.setActiveItem(null);
}

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

/** Emits an event when the user selects an option. */
private _emitChangeEvent(option: MdOption): void {
this._onChange(option.value);
this.change.emit(new MdSelectChange(this, option.value));
if (this._selected !== option) {
this._onChange(option.value);
this.change.emit(new MdSelectChange(this, option.value));
}
}

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

0 comments on commit 7ef1e47

Please sign in to comment.