Skip to content

Commit

Permalink
feat(a11y): manager for list keyboard events (#1599)
Browse files Browse the repository at this point in the history
  • Loading branch information
kara authored Oct 25, 2016
1 parent 2e651e7 commit 95b2a34
Show file tree
Hide file tree
Showing 5 changed files with 195 additions and 50 deletions.
109 changes: 109 additions & 0 deletions src/lib/core/a11y/list-key-manager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {QueryList} from '@angular/core';
import {ListKeyManager, MdFocusable} from './list-key-manager';
import {DOWN_ARROW, UP_ARROW, TAB} from '../keyboard/keycodes';

class FakeFocusable {
disabled = false;
focus() {}
}

const DOWN_ARROW_EVENT = { keyCode: DOWN_ARROW } as KeyboardEvent;
const UP_ARROW_EVENT = { keyCode: UP_ARROW } as KeyboardEvent;
const TAB_EVENT = { keyCode: TAB } as KeyboardEvent;

describe('ListKeyManager', () => {
let keyManager: ListKeyManager;
let itemList: QueryList<MdFocusable>;
let items: MdFocusable[];

beforeEach(() => {
itemList = new QueryList<MdFocusable>();
items = [
new FakeFocusable(),
new FakeFocusable(),
new FakeFocusable()
];

itemList.toArray = () => items;

keyManager = new ListKeyManager(itemList);

// first item is already focused
keyManager.focusedItemIndex = 0;

spyOn(items[0], 'focus');
spyOn(items[1], 'focus');
spyOn(items[2], 'focus');
});

it('should focus subsequent items when down arrow is pressed', () => {
keyManager.onKeydown(DOWN_ARROW_EVENT);

expect(items[0].focus).not.toHaveBeenCalled();
expect(items[1].focus).toHaveBeenCalledTimes(1);
expect(items[2].focus).not.toHaveBeenCalled();

keyManager.onKeydown(DOWN_ARROW_EVENT);
expect(items[0].focus).not.toHaveBeenCalled();
expect(items[1].focus).toHaveBeenCalledTimes(1);
expect(items[2].focus).toHaveBeenCalledTimes(1);
});

it('should focus previous items when up arrow is pressed', () => {
keyManager.onKeydown(DOWN_ARROW_EVENT);

expect(items[0].focus).not.toHaveBeenCalled();
expect(items[1].focus).toHaveBeenCalledTimes(1);

keyManager.onKeydown(UP_ARROW_EVENT);

expect(items[0].focus).toHaveBeenCalledTimes(1);
expect(items[1].focus).toHaveBeenCalledTimes(1);
});

it('should skip disabled items using arrow keys', () => {
items[1].disabled = true;

// down arrow should skip past disabled item from 0 to 2
keyManager.onKeydown(DOWN_ARROW_EVENT);
expect(items[0].focus).not.toHaveBeenCalled();
expect(items[1].focus).not.toHaveBeenCalled();
expect(items[2].focus).toHaveBeenCalledTimes(1);

// up arrow should skip past disabled item from 2 to 0
keyManager.onKeydown(UP_ARROW_EVENT);
expect(items[0].focus).toHaveBeenCalledTimes(1);
expect(items[1].focus).not.toHaveBeenCalled();
expect(items[2].focus).toHaveBeenCalledTimes(1);
});

it('should wrap back to menu when arrow keying past items', () => {
keyManager.onKeydown(DOWN_ARROW_EVENT);
keyManager.onKeydown(DOWN_ARROW_EVENT);

expect(items[0].focus).not.toHaveBeenCalled();
expect(items[1].focus).toHaveBeenCalledTimes(1);
expect(items[2].focus).toHaveBeenCalledTimes(1);

// this down arrow moves down past the end of the list
keyManager.onKeydown(DOWN_ARROW_EVENT);
expect(items[0].focus).toHaveBeenCalledTimes(1);
expect(items[1].focus).toHaveBeenCalledTimes(1);
expect(items[2].focus).toHaveBeenCalledTimes(1);

// this up arrow moves up past the beginning of the list
keyManager.onKeydown(UP_ARROW_EVENT);
expect(items[0].focus).toHaveBeenCalledTimes(1);
expect(items[1].focus).toHaveBeenCalledTimes(1);
expect(items[2].focus).toHaveBeenCalledTimes(2);
});

it('should emit tabOut when the tab key is pressed', () => {
let tabOutEmitted = false;
keyManager.tabOut.first().subscribe(() => tabOutEmitted = true);
keyManager.onKeydown(TAB_EVENT);

expect(tabOutEmitted).toBe(true);
});

});
74 changes: 74 additions & 0 deletions src/lib/core/a11y/list-key-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {EventEmitter, Output, QueryList} from '@angular/core';
import {UP_ARROW, DOWN_ARROW, TAB} from '../core';

/**
* This is the interface for focusable items (used by the ListKeyManager).
* Each item must know how to focus itself and whether or not it is currently disabled.
*/
export interface MdFocusable {
focus(): void;
disabled: boolean;
}

/**
* This class manages keyboard events for selectable lists. If you pass it a query list
* of focusable items, it will focus the correct item when arrow events occur.
*/
export class ListKeyManager {
private _focusedItemIndex: number;

/**
* This event is emitted any time the TAB key is pressed, so components can react
* when focus is shifted off of the list.
*/
@Output() tabOut: EventEmitter<null> = new EventEmitter();

constructor(private _items: QueryList<MdFocusable>) {}

set focusedItemIndex(value: number) {
this._focusedItemIndex = value;
}

onKeydown(event: KeyboardEvent): void {
if (event.keyCode === DOWN_ARROW) {
this._focusNextItem();
} else if (event.keyCode === UP_ARROW) {
this._focusPreviousItem();
} else if (event.keyCode === TAB) {
this.tabOut.emit(null);
}
}

private _focusNextItem(): void {
const items = this._items.toArray();
this._updateFocusedItemIndex(1, items);
items[this._focusedItemIndex].focus();
}

private _focusPreviousItem(): void {
const items = this._items.toArray();
this._updateFocusedItemIndex(-1, items);
items[this._focusedItemIndex].focus();
}

/**
* This method sets focus to the correct item, given a list of items and the delta
* between the currently focused item and the new item to be focused. It will
* continue to move down the list until it finds an item that is not disabled, and it will wrap
* if it encounters either end of the list.
*
* @param delta the desired change in focus index
*/
private _updateFocusedItemIndex(delta: number, items: MdFocusable[]) {
// when focus would leave menu, wrap to beginning or end
this._focusedItemIndex =
(this._focusedItemIndex + delta + items.length) % items.length;

// skip all disabled menu items recursively until an active one
// is reached or the menu closes for overreaching bounds
while (items[this._focusedItemIndex].disabled) {
this._updateFocusedItemIndex(delta, items);
}
}

}
57 changes: 9 additions & 48 deletions src/lib/menu/menu-directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import {MenuPositionX, MenuPositionY} from './menu-positions';
import {MdMenuInvalidPositionX, MdMenuInvalidPositionY} from './menu-errors';
import {MdMenuItem} from './menu-item';
import {UP_ARROW, DOWN_ARROW, TAB} from '../core';
import {ListKeyManager} from '../core/a11y/list-key-manager';

@Component({
moduleId: module.id,
Expand All @@ -27,7 +27,7 @@ import {UP_ARROW, DOWN_ARROW, TAB} from '../core';
exportAs: 'mdMenu'
})
export class MdMenu {
private _focusedItemIndex: number = 0;
private _keyManager: ListKeyManager;

// config object to be passed into the menu's ngClass
_classList: Object;
Expand All @@ -44,6 +44,11 @@ export class MdMenu {
if (posY) { this._setPositionY(posY); }
}

ngAfterContentInit() {
this._keyManager = new ListKeyManager(this.items);
this._keyManager.tabOut.subscribe(() => this._emitCloseEvent());
}

/**
* This method takes classes set on the host md-menu element and applies them on the
* menu template that displays in the overlay container. Otherwise, it's difficult
Expand All @@ -66,62 +71,18 @@ export class MdMenu {
* TODO: internal
*/
_focusFirstItem() {
// The menu always opens with the first item focused.
this.items.first.focus();
this._keyManager.focusedItemIndex = 0;
}

// TODO(kara): update this when (keydown.downArrow) testability is fixed
// TODO: internal
_handleKeydown(event: KeyboardEvent): void {
if (event.keyCode === DOWN_ARROW) {
this._focusNextItem();
} else if (event.keyCode === UP_ARROW) {
this._focusPreviousItem();
} else if (event.keyCode === TAB) {
this._emitCloseEvent();
}
}

/**
* This emits a close event to which the trigger is subscribed. When emitted, the
* trigger will close the menu.
*/
private _emitCloseEvent(): void {
this._focusedItemIndex = 0;
this.close.emit(null);
}

private _focusNextItem(): void {
this._updateFocusedItemIndex(1);
this.items.toArray()[this._focusedItemIndex].focus();
}

private _focusPreviousItem(): void {
this._updateFocusedItemIndex(-1);
this.items.toArray()[this._focusedItemIndex].focus();
}

/**
* This method sets focus to the correct menu item, given a list of menu items and the delta
* between the currently focused menu item and the new menu item to be focused. It will
* continue to move down the list until it finds an item that is not disabled, and it will wrap
* if it encounters either end of the menu.
*
* @param delta the desired change in focus index
* @param menuItems the menu items that should be focused
* @private
*/
private _updateFocusedItemIndex(delta: number, menuItems: MdMenuItem[] = this.items.toArray()) {
// when focus would leave menu, wrap to beginning or end
this._focusedItemIndex = (this._focusedItemIndex + delta + this.items.length)
% this.items.length;

// skip all disabled menu items recursively until an active one
// is reached or the menu closes for overreaching bounds
while (menuItems[this._focusedItemIndex].disabled) {
this._updateFocusedItemIndex(delta, menuItems);
}
}

private _setPositionX(pos: MenuPositionX): void {
if ( pos !== 'before' && pos !== 'after') {
throw new MdMenuInvalidPositionX();
Expand Down
3 changes: 2 additions & 1 deletion src/lib/menu/menu-item.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {Directive, ElementRef, Input, HostBinding, Renderer} from '@angular/core';
import {MdFocusable} from '../core/a11y/list-key-manager';

/**
* This directive is intended to be used inside an md-menu tag.
Expand All @@ -13,7 +14,7 @@ import {Directive, ElementRef, Input, HostBinding, Renderer} from '@angular/core
},
exportAs: 'mdMenuItem'
})
export class MdMenuItem {
export class MdMenuItem implements MdFocusable {
_disabled: boolean;

constructor(private _renderer: Renderer, private _elementRef: ElementRef) {}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/menu/menu.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div class="md-menu-panel" [ngClass]="_classList"
(click)="_emitCloseEvent()" (keydown)="_handleKeydown($event)">
(click)="_emitCloseEvent()" (keydown)="_keyManager.onKeydown($event)">
<ng-content></ng-content>
</div>
</template>
Expand Down

0 comments on commit 95b2a34

Please sign in to comment.