Skip to content
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

Merged
merged 1 commit into from
Oct 25, 2016
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/lib/core/a11y/list-key-manager.spec.ts
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);
});

});
74 changes: 74 additions & 0 deletions src/lib/core/a11y/list-key-manager.ts
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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add interface description

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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should have unit tests for just this class (with some fake MdFocusable items)

Copy link
Member

Choose a reason for hiding this comment

The 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();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add description for tabOut

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be void instead of null?

Copy link
Contributor Author

@kara kara Oct 25, 2016

Choose a reason for hiding this comment

The 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);
}
}

}
57 changes: 9 additions & 48 deletions src/lib/menu/menu-directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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
Expand All @@ -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;
Copy link
Member

Choose a reason for hiding this comment

The 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();
Expand Down
3 changes: 2 additions & 1 deletion src/lib/menu/menu-item.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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) {}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/menu/menu.html
Original file line number Diff line number Diff line change
@@ -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>
Expand Down