-
Notifications
You must be signed in to change notification settings - Fork 6.8k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(a11y): manager for list keyboard events #1599
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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); | ||
}); | ||
|
||
}); |
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should have unit tests for just this class (with some fake There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add class description |
||
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(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add description for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I can change. edit: we discussed in person |
||
|
||
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); | ||
} | ||
} | ||
|
||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, | ||
|
@@ -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; | ||
|
@@ -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 | ||
|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add comment like // The menu always opens with the first item focused. |
||
} | ||
|
||
// 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(); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add interface description