-
Notifications
You must be signed in to change notification settings - Fork 6.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(a11y): add wrap mode to key manager (#1796)
- Loading branch information
Showing
5 changed files
with
354 additions
and
122 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 |
---|---|---|
@@ -1,125 +1,293 @@ | ||
import {QueryList} from '@angular/core'; | ||
import {ListKeyManager, MdFocusable} from './list-key-manager'; | ||
import {DOWN_ARROW, UP_ARROW, TAB} from '../keyboard/keycodes'; | ||
import {ListKeyManager} from './list-key-manager'; | ||
import {DOWN_ARROW, UP_ARROW, TAB, HOME, END} from '../keyboard/keycodes'; | ||
|
||
class FakeFocusable { | ||
disabled = false; | ||
focus() {} | ||
} | ||
|
||
class FakeQueryList<T> extends QueryList<T> { | ||
get length() { return this.items.length; } | ||
items: T[]; | ||
toArray() { | ||
return this.items; | ||
} | ||
} | ||
|
||
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; | ||
const HOME_EVENT = { keyCode: HOME } as KeyboardEvent; | ||
const END_EVENT = { keyCode: END } as KeyboardEvent; | ||
|
||
describe('ListKeyManager', () => { | ||
let keyManager: ListKeyManager; | ||
let itemList: QueryList<MdFocusable>; | ||
let items: MdFocusable[]; | ||
let itemList: FakeQueryList<FakeFocusable>; | ||
|
||
beforeEach(() => { | ||
itemList = new QueryList<MdFocusable>(); | ||
items = [ | ||
itemList = new FakeQueryList<FakeFocusable>(); | ||
itemList.items = [ | ||
new FakeFocusable(), | ||
new FakeFocusable(), | ||
new FakeFocusable() | ||
]; | ||
|
||
itemList.toArray = () => items; | ||
|
||
keyManager = new ListKeyManager(itemList); | ||
|
||
// first item is already focused | ||
keyManager.focusedItemIndex = 0; | ||
keyManager.focusFirstItem(); | ||
|
||
spyOn(items[0], 'focus'); | ||
spyOn(items[1], 'focus'); | ||
spyOn(items[2], 'focus'); | ||
spyOn(itemList.items[0], 'focus'); | ||
spyOn(itemList.items[1], 'focus'); | ||
spyOn(itemList.items[2], 'focus'); | ||
}); | ||
|
||
it('should focus subsequent items when down arrow is pressed', () => { | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
describe('key events', () => { | ||
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(); | ||
expect(itemList.items[0].focus).not.toHaveBeenCalled(); | ||
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); | ||
expect(itemList.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); | ||
}); | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
expect(itemList.items[0].focus).not.toHaveBeenCalled(); | ||
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); | ||
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('should focus previous items when up arrow is pressed', () => { | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
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); | ||
expect(itemList.items[0].focus).not.toHaveBeenCalled(); | ||
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); | ||
|
||
keyManager.onKeydown(UP_ARROW_EVENT); | ||
keyManager.onKeydown(UP_ARROW_EVENT); | ||
|
||
expect(items[0].focus).toHaveBeenCalledTimes(1); | ||
expect(items[1].focus).toHaveBeenCalledTimes(1); | ||
}); | ||
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1); | ||
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('should skip disabled items using arrow keys', () => { | ||
items[1].disabled = true; | ||
it('should skip disabled items using arrow keys', () => { | ||
itemList.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); | ||
// down arrow should skip past disabled item from 0 to 2 | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
expect(itemList.items[0].focus).not.toHaveBeenCalled(); | ||
expect(itemList.items[1].focus).not.toHaveBeenCalled(); | ||
expect(itemList.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); | ||
}); | ||
// up arrow should skip past disabled item from 2 to 0 | ||
keyManager.onKeydown(UP_ARROW_EVENT); | ||
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1); | ||
expect(itemList.items[1].focus).not.toHaveBeenCalled(); | ||
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('should work normally when disabled property does not exist', () => { | ||
itemList.items[0].disabled = undefined; | ||
itemList.items[1].disabled = undefined; | ||
itemList.items[2].disabled = undefined; | ||
|
||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
expect(itemList.items[0].focus).not.toHaveBeenCalled(); | ||
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); | ||
expect(itemList.items[2].focus).not.toHaveBeenCalled(); | ||
|
||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
expect(itemList.items[0].focus).not.toHaveBeenCalled(); | ||
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); | ||
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('should not move focus past either end of the list', () => { | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(2, `Expected focus to be on the last item of the list.`); | ||
|
||
// this down arrow would move focus past the end of the list | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(2, `Expected focus to remain at the end of the list.`); | ||
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); | ||
|
||
it('should work normally when disabled property does not exist', () => { | ||
items[0].disabled = undefined; | ||
items[1].disabled = undefined; | ||
items[2].disabled = undefined; | ||
keyManager.onKeydown(UP_ARROW_EVENT); | ||
keyManager.onKeydown(UP_ARROW_EVENT); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(0, `Expected focus to be on the first item of the list.`); | ||
|
||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
expect(items[0].focus).not.toHaveBeenCalled(); | ||
expect(items[1].focus).toHaveBeenCalledTimes(1); | ||
expect(items[2].focus).not.toHaveBeenCalled(); | ||
// this up arrow would move focus past the beginning of the list | ||
keyManager.onKeydown(UP_ARROW_EVENT); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(0, `Expected focus to remain at the beginning of the list.`); | ||
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('should not move focus when the last item is disabled', () => { | ||
itemList.items[2].disabled = true; | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(1, `Expected focus to be on the second item of the list.`); | ||
|
||
// this down arrow would move focus the last item, which is disabled | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(1, `Expected focus to remain on the second item.`); | ||
expect(itemList.items[2].focus).not.toHaveBeenCalled(); | ||
}); | ||
|
||
it('should focus the first item when HOME is pressed', () => { | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(2, `Expected focus to be on the last item of the list.`); | ||
|
||
keyManager.onKeydown(HOME_EVENT); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(0, `Expected the HOME key to move the focus back to the first item.`); | ||
}); | ||
|
||
it('should focus the last item when END is pressed', () => { | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(0, `Expected focus to be on the first item of the list.`); | ||
|
||
keyManager.onKeydown(END_EVENT); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(2, `Expected the END key to move the focus to the last item in the list.`); | ||
}); | ||
|
||
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); | ||
}); | ||
|
||
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 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); | ||
describe('programmatic focus', () => { | ||
|
||
it('should setFocus()', () => { | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(0, `Expected focus to be on the first item of the list.`); | ||
|
||
keyManager.setFocus(1); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(1, `Expected focusedItemIndex to be updated when setFocus() was called.`); | ||
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); | ||
}); | ||
|
||
it('should focus the first item when focusFirstItem() is called', () => { | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(2, `Expected focus to be on the last item of the list.`); | ||
|
||
keyManager.focusFirstItem(); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(0, `Expected focusFirstItem() to move the focus back to the first item.`); | ||
}); | ||
|
||
it('should focus the second item if the first one is disabled', () => { | ||
itemList.items[0].disabled = true; | ||
|
||
keyManager.focusFirstItem(); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(1, `Expected the second item to be focused if the first was disabled.`); | ||
}); | ||
|
||
it('should focus the last item when focusLastItem() is called', () => { | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(0, `Expected focus to be on the first item of the list.`); | ||
|
||
keyManager.focusLastItem(); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(2, `Expected focusLastItem() to move the focus to the last item in the list.`); | ||
}); | ||
|
||
it('should focus the second to last item if the last one is disabled', () => { | ||
itemList.items[2].disabled = true; | ||
|
||
keyManager.focusLastItem(); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(1, `Expected the second to last item to be focused if the last was disabled.`); | ||
}); | ||
|
||
it('should focus the next item when focusNextItem() is called', () => { | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(0, `Expected focus to be on the first item of the list.`); | ||
|
||
keyManager.focusNextItem(); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(1, `Expected focusNextItem() to move the focus to the next item.`); | ||
}); | ||
|
||
it('should focus the next enabled item if next is disabled', () => { | ||
itemList.items[1].disabled = true; | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(0, `Expected focus to be on the first item of the list.`); | ||
|
||
keyManager.focusNextItem(); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(2, `Expected focusNextItem() to focus only enabled items.`); | ||
}); | ||
|
||
it('should focus the previous item when focusPreviousItem() is called', () => { | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(1, `Expected focus to be on the second item of the list.`); | ||
|
||
keyManager.focusPreviousItem(); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(0, `Expected focusPreviousItem() to move the focus to the last item.`); | ||
}); | ||
|
||
it('should skip disabled items when focusPreviousItem() is called', () => { | ||
itemList.items[1].disabled = true; | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(2, `Expected focus to be on the third item of the list.`); | ||
|
||
keyManager.focusPreviousItem(); | ||
expect(keyManager.focusedItemIndex) | ||
.toBe(0, `Expected focusPreviousItem() to skip the disabled item.`); | ||
}); | ||
|
||
}); | ||
|
||
it('should emit tabOut when the tab key is pressed', () => { | ||
let tabOutEmitted = false; | ||
keyManager.tabOut.first().subscribe(() => tabOutEmitted = true); | ||
keyManager.onKeydown(TAB_EVENT); | ||
describe('wrap mode', () => { | ||
|
||
it('should return itself to allow chaining', () => { | ||
expect(keyManager.withFocusWrap()) | ||
.toEqual(keyManager, `Expected withFocusWrap() to return an instance of ListKeyManager`); | ||
}); | ||
|
||
it('should wrap focus when arrow keying past items while in wrap mode', () => { | ||
keyManager.withFocusWrap(); | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
|
||
expect(itemList.items[0].focus).not.toHaveBeenCalled(); | ||
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); | ||
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); | ||
|
||
// this down arrow moves down past the end of the list | ||
keyManager.onKeydown(DOWN_ARROW_EVENT); | ||
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1); | ||
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); | ||
expect(itemList.items[2].focus).toHaveBeenCalledTimes(1); | ||
|
||
// this up arrow moves up past the beginning of the list | ||
keyManager.onKeydown(UP_ARROW_EVENT); | ||
expect(itemList.items[0].focus).toHaveBeenCalledTimes(1); | ||
expect(itemList.items[1].focus).toHaveBeenCalledTimes(1); | ||
expect(itemList.items[2].focus).toHaveBeenCalledTimes(2); | ||
}); | ||
|
||
expect(tabOutEmitted).toBe(true); | ||
}); | ||
|
||
}); |
Oops, something went wrong.