-
Notifications
You must be signed in to change notification settings - Fork 6.8k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(a11y): manager for list keyboard events
- Loading branch information
Showing
5 changed files
with
195 additions
and
50 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 MockFocusable { | ||
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 MockFocusable(), | ||
new MockFocusable(), | ||
new MockFocusable() | ||
]; | ||
|
||
itemList.toArray =() => { return 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); | ||
}); | ||
|
||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters