From 95b2a3494971536afe10213bb70d4815297d99dd Mon Sep 17 00:00:00 2001 From: Kara Date: Tue, 25 Oct 2016 15:02:14 -0700 Subject: [PATCH] feat(a11y): manager for list keyboard events (#1599) --- src/lib/core/a11y/list-key-manager.spec.ts | 109 +++++++++++++++++++++ src/lib/core/a11y/list-key-manager.ts | 74 ++++++++++++++ src/lib/menu/menu-directive.ts | 57 ++--------- src/lib/menu/menu-item.ts | 3 +- src/lib/menu/menu.html | 2 +- 5 files changed, 195 insertions(+), 50 deletions(-) create mode 100644 src/lib/core/a11y/list-key-manager.spec.ts create mode 100644 src/lib/core/a11y/list-key-manager.ts diff --git a/src/lib/core/a11y/list-key-manager.spec.ts b/src/lib/core/a11y/list-key-manager.spec.ts new file mode 100644 index 000000000000..e09dafeb99b5 --- /dev/null +++ b/src/lib/core/a11y/list-key-manager.spec.ts @@ -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; + let items: MdFocusable[]; + + beforeEach(() => { + itemList = new QueryList(); + 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); + }); + +}); diff --git a/src/lib/core/a11y/list-key-manager.ts b/src/lib/core/a11y/list-key-manager.ts new file mode 100644 index 000000000000..8eb080090836 --- /dev/null +++ b/src/lib/core/a11y/list-key-manager.ts @@ -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 = new EventEmitter(); + + constructor(private _items: QueryList) {} + + 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); + } + } + +} diff --git a/src/lib/menu/menu-directive.ts b/src/lib/menu/menu-directive.ts index 2a3eeaa36b7c..b1bc3ab78321 100644 --- a/src/lib/menu/menu-directive.ts +++ b/src/lib/menu/menu-directive.ts @@ -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; } - - // 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(); diff --git a/src/lib/menu/menu-item.ts b/src/lib/menu/menu-item.ts index 2b70779ebbf6..06df0d60a9c1 100644 --- a/src/lib/menu/menu-item.ts +++ b/src/lib/menu/menu-item.ts @@ -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. @@ -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) {} diff --git a/src/lib/menu/menu.html b/src/lib/menu/menu.html index 58721749878e..f23266c05da9 100644 --- a/src/lib/menu/menu.html +++ b/src/lib/menu/menu.html @@ -1,6 +1,6 @@