mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(a11y): manager for list keyboard events (#1599)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="md-menu-panel" [ngClass]="_classList"
|
||||
(click)="_emitCloseEvent()" (keydown)="_handleKeydown($event)">
|
||||
(click)="_emitCloseEvent()" (keydown)="_keyManager.onKeydown($event)">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user