Skip to content

Commit 415b5a4

Browse files
committed
feat(a11y): manager for list keyboard events
1 parent c0e6f83 commit 415b5a4

File tree

5 files changed

+195
-50
lines changed

5 files changed

+195
-50
lines changed
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import {QueryList} from '@angular/core';
2+
import {ListKeyManager, MdFocusable} from './list-key-manager';
3+
import {DOWN_ARROW, UP_ARROW, TAB} from '../keyboard/keycodes';
4+
5+
class MockFocusable {
6+
disabled = false;
7+
focus() {}
8+
}
9+
10+
const DOWN_ARROW_EVENT = { keyCode: DOWN_ARROW } as KeyboardEvent;
11+
const UP_ARROW_EVENT = { keyCode: UP_ARROW } as KeyboardEvent;
12+
const TAB_EVENT = { keyCode: TAB } as KeyboardEvent;
13+
14+
describe('ListKeyManager', () => {
15+
let keyManager: ListKeyManager;
16+
let itemList: QueryList<MdFocusable>;
17+
let items: MdFocusable[];
18+
19+
beforeEach(() => {
20+
itemList = new QueryList<MdFocusable>();
21+
items = [
22+
new MockFocusable(),
23+
new MockFocusable(),
24+
new MockFocusable()
25+
];
26+
27+
itemList.toArray = () => { return items; };
28+
29+
keyManager = new ListKeyManager(itemList);
30+
31+
// first item is already focused
32+
keyManager.focusedItemIndex = 0;
33+
34+
spyOn(items[0], 'focus');
35+
spyOn(items[1], 'focus');
36+
spyOn(items[2], 'focus');
37+
});
38+
39+
it('should focus subsequent items when down arrow is pressed', () => {
40+
keyManager.onKeydown(DOWN_ARROW_EVENT);
41+
42+
expect(items[0].focus).not.toHaveBeenCalled();
43+
expect(items[1].focus).toHaveBeenCalledTimes(1);
44+
expect(items[2].focus).not.toHaveBeenCalled();
45+
46+
keyManager.onKeydown(DOWN_ARROW_EVENT);
47+
expect(items[0].focus).not.toHaveBeenCalled();
48+
expect(items[1].focus).toHaveBeenCalledTimes(1);
49+
expect(items[2].focus).toHaveBeenCalledTimes(1);
50+
});
51+
52+
it('should focus previous items when up arrow is pressed', () => {
53+
keyManager.onKeydown(DOWN_ARROW_EVENT);
54+
55+
expect(items[0].focus).not.toHaveBeenCalled();
56+
expect(items[1].focus).toHaveBeenCalledTimes(1);
57+
58+
keyManager.onKeydown(UP_ARROW_EVENT);
59+
60+
expect(items[0].focus).toHaveBeenCalledTimes(1);
61+
expect(items[1].focus).toHaveBeenCalledTimes(1);
62+
});
63+
64+
it('should skip disabled items using arrow keys', () => {
65+
items[1].disabled = true;
66+
67+
// down arrow should skip past disabled item from 0 to 2
68+
keyManager.onKeydown(DOWN_ARROW_EVENT);
69+
expect(items[0].focus).not.toHaveBeenCalled();
70+
expect(items[1].focus).not.toHaveBeenCalled();
71+
expect(items[2].focus).toHaveBeenCalledTimes(1);
72+
73+
// up arrow should skip past disabled item from 2 to 0
74+
keyManager.onKeydown(UP_ARROW_EVENT);
75+
expect(items[0].focus).toHaveBeenCalledTimes(1);
76+
expect(items[1].focus).not.toHaveBeenCalled();
77+
expect(items[2].focus).toHaveBeenCalledTimes(1);
78+
});
79+
80+
it('should wrap back to menu when arrow keying past items', () => {
81+
keyManager.onKeydown(DOWN_ARROW_EVENT);
82+
keyManager.onKeydown(DOWN_ARROW_EVENT);
83+
84+
expect(items[0].focus).not.toHaveBeenCalled();
85+
expect(items[1].focus).toHaveBeenCalledTimes(1);
86+
expect(items[2].focus).toHaveBeenCalledTimes(1);
87+
88+
// this down arrow moves down past the end of the list
89+
keyManager.onKeydown(DOWN_ARROW_EVENT);
90+
expect(items[0].focus).toHaveBeenCalledTimes(1);
91+
expect(items[1].focus).toHaveBeenCalledTimes(1);
92+
expect(items[2].focus).toHaveBeenCalledTimes(1);
93+
94+
// this up arrow moves up past the beginning of the list
95+
keyManager.onKeydown(UP_ARROW_EVENT);
96+
expect(items[0].focus).toHaveBeenCalledTimes(1);
97+
expect(items[1].focus).toHaveBeenCalledTimes(1);
98+
expect(items[2].focus).toHaveBeenCalledTimes(2);
99+
});
100+
101+
it('should emit tabOut when the tab key is pressed', () => {
102+
let tabOutEmitted = false;
103+
keyManager.tabOut.first().subscribe(() => tabOutEmitted = true);
104+
keyManager.onKeydown(TAB_EVENT);
105+
106+
expect(tabOutEmitted).toBe(true);
107+
});
108+
109+
});

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {EventEmitter, Output, QueryList} from '@angular/core';
2+
import {UP_ARROW, DOWN_ARROW, TAB} from '../core';
3+
4+
/**
5+
* This is the interface for focusable items (used by the ListKeyManager).
6+
* Each item must know how to focus itself and whether or not it is currently disabled.
7+
*/
8+
export interface MdFocusable {
9+
focus(): void;
10+
disabled: boolean;
11+
}
12+
13+
/**
14+
* This class manages keyboard events for selectable lists. If you pass it a query list
15+
* of focusable items, it will focus the correct item when arrow events occur.
16+
*/
17+
export class ListKeyManager {
18+
private _focusedItemIndex: number;
19+
20+
/**
21+
* This event is emitted any time the TAB key is pressed, so components can react
22+
* when focus is shifted off of the list.
23+
*/
24+
@Output() tabOut: EventEmitter<null> = new EventEmitter();
25+
26+
constructor(private _items: QueryList<MdFocusable>) {}
27+
28+
set focusedItemIndex(value: number) {
29+
this._focusedItemIndex = value;
30+
}
31+
32+
onKeydown(event: KeyboardEvent): void {
33+
if (event.keyCode === DOWN_ARROW) {
34+
this._focusNextItem();
35+
} else if (event.keyCode === UP_ARROW) {
36+
this._focusPreviousItem();
37+
} else if (event.keyCode === TAB) {
38+
this.tabOut.emit(null);
39+
}
40+
}
41+
42+
private _focusNextItem(): void {
43+
const items = this._items.toArray();
44+
this._updateFocusedItemIndex(1, items);
45+
items[this._focusedItemIndex].focus();
46+
}
47+
48+
private _focusPreviousItem(): void {
49+
const items = this._items.toArray();
50+
this._updateFocusedItemIndex(-1, items);
51+
items[this._focusedItemIndex].focus();
52+
}
53+
54+
/**
55+
* This method sets focus to the correct item, given a list of items and the delta
56+
* between the currently focused item and the new item to be focused. It will
57+
* continue to move down the list until it finds an item that is not disabled, and it will wrap
58+
* if it encounters either end of the list.
59+
*
60+
* @param delta the desired change in focus index
61+
*/
62+
private _updateFocusedItemIndex(delta: number, items: MdFocusable[]) {
63+
// when focus would leave menu, wrap to beginning or end
64+
this._focusedItemIndex =
65+
(this._focusedItemIndex + delta + items.length) % items.length;
66+
67+
// skip all disabled menu items recursively until an active one
68+
// is reached or the menu closes for overreaching bounds
69+
while (items[this._focusedItemIndex].disabled) {
70+
this._updateFocusedItemIndex(delta, items);
71+
}
72+
}
73+
74+
}

src/lib/menu/menu-directive.ts

Lines changed: 9 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import {
1515
import {MenuPositionX, MenuPositionY} from './menu-positions';
1616
import {MdMenuInvalidPositionX, MdMenuInvalidPositionY} from './menu-errors';
1717
import {MdMenuItem} from './menu-item';
18-
import {UP_ARROW, DOWN_ARROW, TAB} from '../core';
18+
import {ListKeyManager} from '../core/a11y/list-key-manager';
1919

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

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

47+
ngAfterContentInit() {
48+
this._keyManager = new ListKeyManager(this.items);
49+
this._keyManager.tabOut.subscribe(() => this._emitCloseEvent());
50+
}
51+
4752
/**
4853
* This method takes classes set on the host md-menu element and applies them on the
4954
* menu template that displays in the overlay container. Otherwise, it's difficult
@@ -66,62 +71,18 @@ export class MdMenu {
6671
* TODO: internal
6772
*/
6873
_focusFirstItem() {
74+
// The menu always opens with the first item focused.
6975
this.items.first.focus();
76+
this._keyManager.focusedItemIndex = 0;
7077
}
71-
72-
// TODO(kara): update this when (keydown.downArrow) testability is fixed
73-
// TODO: internal
74-
_handleKeydown(event: KeyboardEvent): void {
75-
if (event.keyCode === DOWN_ARROW) {
76-
this._focusNextItem();
77-
} else if (event.keyCode === UP_ARROW) {
78-
this._focusPreviousItem();
79-
} else if (event.keyCode === TAB) {
80-
this._emitCloseEvent();
81-
}
82-
}
83-
8478
/**
8579
* This emits a close event to which the trigger is subscribed. When emitted, the
8680
* trigger will close the menu.
8781
*/
8882
private _emitCloseEvent(): void {
89-
this._focusedItemIndex = 0;
9083
this.close.emit(null);
9184
}
9285

93-
private _focusNextItem(): void {
94-
this._updateFocusedItemIndex(1);
95-
this.items.toArray()[this._focusedItemIndex].focus();
96-
}
97-
98-
private _focusPreviousItem(): void {
99-
this._updateFocusedItemIndex(-1);
100-
this.items.toArray()[this._focusedItemIndex].focus();
101-
}
102-
103-
/**
104-
* This method sets focus to the correct menu item, given a list of menu items and the delta
105-
* between the currently focused menu item and the new menu item to be focused. It will
106-
* continue to move down the list until it finds an item that is not disabled, and it will wrap
107-
* if it encounters either end of the menu.
108-
*
109-
* @param delta the desired change in focus index
110-
* @param menuItems the menu items that should be focused
111-
* @private
112-
*/
113-
private _updateFocusedItemIndex(delta: number, menuItems: MdMenuItem[] = this.items.toArray()) {
114-
// when focus would leave menu, wrap to beginning or end
115-
this._focusedItemIndex = (this._focusedItemIndex + delta + this.items.length)
116-
% this.items.length;
117-
118-
// skip all disabled menu items recursively until an active one
119-
// is reached or the menu closes for overreaching bounds
120-
while (menuItems[this._focusedItemIndex].disabled) {
121-
this._updateFocusedItemIndex(delta, menuItems);
122-
}
123-
}
124-
12586
private _setPositionX(pos: MenuPositionX): void {
12687
if ( pos !== 'before' && pos !== 'after') {
12788
throw new MdMenuInvalidPositionX();

src/lib/menu/menu-item.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {Directive, ElementRef, Input, HostBinding, Renderer} from '@angular/core';
2+
import {MdFocusable} from '../core/a11y/list-key-manager';
23

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

1920
constructor(private _renderer: Renderer, private _elementRef: ElementRef) {}

src/lib/menu/menu.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<template>
22
<div class="md-menu-panel" [ngClass]="_classList"
3-
(click)="_emitCloseEvent()" (keydown)="_handleKeydown($event)">
3+
(click)="_emitCloseEvent()" (keydown)="_keyManager.onKeydown($event)">
44
<ng-content></ng-content>
55
</div>
66
</template>

0 commit comments

Comments
 (0)