Skip to content

Commit 3669f06

Browse files
authored
feat(menu): add keyboard events and improve accessibility (#1132)
1 parent 4f2bc66 commit 3669f06

File tree

13 files changed

+259
-53
lines changed

13 files changed

+259
-53
lines changed

e2e/components/menu/menu-page.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export class MenuPage {
88

99
menu() { return element(by.css('.md-menu')); }
1010

11+
start() { return element(by.id('start')); }
12+
1113
trigger() { return element(by.id('trigger')); }
1214

1315
triggerTwo() { return element(by.id('trigger-two')); }
@@ -32,6 +34,17 @@ export class MenuPage {
3234

3335
combinedMenu() { return element(by.css('.md-menu.combined')); }
3436

37+
// TODO(kara): move to common testing utility
38+
pressKey(key: any): void {
39+
browser.actions().sendKeys(key).perform();
40+
}
41+
42+
// TODO(kara): move to common testing utility
43+
expectFocusOn(el: ElementFinder): void {
44+
expect(browser.driver.switchTo().activeElement().getInnerHtml())
45+
.toBe(el.getInnerHtml());
46+
}
47+
3548
expectMenuPresent(expected: boolean) {
3649
return browser.isElementPresent(by.css('.md-menu')).then((isPresent) => {
3750
expect(isPresent).toBe(expected);

e2e/components/menu/menu.e2e.ts

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ describe('menu', () => {
1212
page.trigger().click();
1313

1414
page.expectMenuPresent(true);
15-
expect(page.menu().getText()).toEqual("One\nTwo\nThree");
15+
expect(page.menu().getText()).toEqual("One\nTwo\nThree\nFour");
1616
});
1717

1818
it('should close menu when area outside menu is clicked', () => {
@@ -45,14 +45,14 @@ describe('menu', () => {
4545

4646
it('should support multiple triggers opening the same menu', () => {
4747
page.triggerTwo().click();
48-
expect(page.menu().getText()).toEqual("One\nTwo\nThree");
48+
expect(page.menu().getText()).toEqual("One\nTwo\nThree\nFour");
4949
page.expectMenuAlignedWith(page.menu(), 'trigger-two');
5050

5151
page.body().click();
5252
page.expectMenuPresent(false);
5353

5454
page.trigger().click();
55-
expect(page.menu().getText()).toEqual("One\nTwo\nThree");
55+
expect(page.menu().getText()).toEqual("One\nTwo\nThree\nFour");
5656
page.expectMenuAlignedWith(page.menu(), 'trigger');
5757

5858
page.body().click();
@@ -66,6 +66,84 @@ describe('menu', () => {
6666
});
6767
});
6868

69+
describe('keyboard events', () => {
70+
beforeEach(() => {
71+
// click start button to avoid tabbing past navigation
72+
page.start().click();
73+
page.pressKey(protractor.Key.TAB);
74+
});
75+
76+
it('should auto-focus the first item when opened with keyboard', () => {
77+
page.pressKey(protractor.Key.ENTER);
78+
page.expectFocusOn(page.items(0));
79+
});
80+
81+
it('should not focus the first item when opened with mouse', () => {
82+
page.trigger().click();
83+
page.expectFocusOn(page.trigger());
84+
});
85+
86+
it('should focus subsequent items when down arrow is pressed', () => {
87+
page.pressKey(protractor.Key.ENTER);
88+
page.pressKey(protractor.Key.DOWN);
89+
page.expectFocusOn(page.items(1));
90+
});
91+
92+
it('should focus previous items when up arrow is pressed', () => {
93+
page.pressKey(protractor.Key.ENTER);
94+
page.pressKey(protractor.Key.DOWN);
95+
page.pressKey(protractor.Key.UP);
96+
page.expectFocusOn(page.items(0));
97+
});
98+
99+
it('should skip disabled items using arrow keys', () => {
100+
page.pressKey(protractor.Key.ENTER);
101+
page.pressKey(protractor.Key.DOWN);
102+
page.pressKey(protractor.Key.DOWN);
103+
page.expectFocusOn(page.items(3));
104+
105+
page.pressKey(protractor.Key.UP);
106+
page.expectFocusOn(page.items(1));
107+
});
108+
109+
it('should close the menu when tabbing past items', () => {
110+
page.pressKey(protractor.Key.ENTER);
111+
page.pressKey(protractor.Key.TAB);
112+
page.expectMenuPresent(false);
113+
114+
page.start().click();
115+
page.pressKey(protractor.Key.TAB);
116+
page.pressKey(protractor.Key.ENTER);
117+
page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
118+
page.expectMenuPresent(false);
119+
});
120+
121+
it('should wrap back to menu when arrow keying past items', () => {
122+
page.pressKey(protractor.Key.ENTER);
123+
page.pressKey(protractor.Key.DOWN);
124+
page.pressKey(protractor.Key.DOWN);
125+
page.pressKey(protractor.Key.DOWN);
126+
page.expectFocusOn(page.items(0));
127+
128+
page.pressKey(protractor.Key.UP);
129+
page.expectFocusOn(page.items(3));
130+
});
131+
132+
it('should focus before and after trigger when tabbing past items', () => {
133+
page.pressKey(protractor.Key.ENTER);
134+
page.pressKey(protractor.Key.TAB);
135+
page.expectFocusOn(page.triggerTwo());
136+
137+
// navigate back to trigger
138+
page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
139+
page.pressKey(protractor.Key.ENTER);
140+
141+
page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
142+
page.expectFocusOn(page.start());
143+
});
144+
145+
});
146+
69147
describe('position - ', () => {
70148

71149
it('should default menu alignment to "after below" when not set', () => {

src/demo-app/menu/menu-demo.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ export class MenuDemo {
1212
items = [
1313
{text: 'Refresh'},
1414
{text: 'Settings'},
15-
{text: 'Help'},
16-
{text: 'Sign Out', disabled: true}
15+
{text: 'Help', disabled: true},
16+
{text: 'Sign Out'}
1717
];
1818

1919
select(text: string) { this.selected = text; }

src/e2e-app/menu/menu-e2e.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
<div>
22
<div style="float:left">
33
<div id="text">{{ selected }}</div>
4+
<button id="start">START</button>
45
<button [md-menu-trigger-for]="menu" id="trigger">TRIGGER</button>
56
<button [md-menu-trigger-for]="menu" id="trigger-two">TRIGGER 2</button>
67

78
<md-menu #menu="mdMenu" class="custom">
89
<button md-menu-item (click)="selected='one'">One</button>
910
<button md-menu-item (click)="selected='two'">Two</button>
1011
<button md-menu-item (click)="selected='three'" disabled>Three</button>
12+
<button md-menu-item>Four</button>
1113
</md-menu>
1214

1315
<button [md-menu-trigger-for]="beforeMenu" id="before-t">

src/lib/core/keyboard/keycodes.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
2+
// Due to a bug in the ChromeDriver, Angular 2 keyboard events are not triggered by `sendKeys`
3+
// during E2E tests when using dot notation such as `(keydown.rightArrow)`. To get around this,
4+
// we are temporarily using a single (keydown) handler.
5+
// See: https://github.com/angular/angular/issues/9419
6+
7+
export const UP_ARROW = 38;
8+
export const DOWN_ARROW = 40;
9+
export const RIGHT_ARROW = 39;
10+
export const LEFT_ARROW = 37;
11+
12+
export const ENTER = 13;
13+
export const TAB = 9;

src/lib/menu/README.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
### Not yet implemented
77

8-
- Keyboard events: up arrow, down arrow, enter
98
- `prevent-close` option, to turn off automatic menu close when clicking outside the menu
109
- Custom offset support
1110
- Menu groupings (which menus are allowed to open together)
@@ -129,7 +128,12 @@ Output:
129128
### Accessibility
130129

131130
The menu adds `role="menu"` to the main menu element and `role="menuitem"` to each menu item. It
132-
also adds `aria-hasPopup="true"` to the trigger element.
131+
also adds `aria-hasPopup="true"` to the trigger element.
132+
133+
#### Keyboard events:
134+
- <kbd>DOWN_ARROW</kbd>: Focus next menu item
135+
- <kbd>UP_ARROW</kbd>: Focus previous menu item
136+
- <kbd>ENTER</kbd>: Select focused item
133137

134138
### Menu attributes
135139

@@ -158,4 +162,3 @@ also adds `aria-hasPopup="true"` to the trigger element.
158162
| `destroyMenu()` | `Promise<void>` | Destroys the menu overlay completely.
159163

160164

161-

src/lib/menu/menu-directive.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
1-
// TODO(kara): keyboard events for menu navigation
21
// TODO(kara): prevent-close functionality
32

43
import {
54
Attribute,
65
Component,
6+
ContentChildren,
77
EventEmitter,
88
Input,
99
Output,
10+
QueryList,
1011
TemplateRef,
1112
ViewChild,
1213
ViewEncapsulation
1314
} from '@angular/core';
1415
import {MenuPositionX, MenuPositionY} from './menu-positions';
1516
import {MdMenuInvalidPositionX, MdMenuInvalidPositionY} from './menu-errors';
17+
import {MdMenuItem} from './menu-item';
18+
import {UP_ARROW, DOWN_ARROW, TAB} from '@angular2-material/core/keyboard/keycodes';
1619

1720
@Component({
1821
moduleId: module.id,
@@ -25,6 +28,7 @@ import {MdMenuInvalidPositionX, MdMenuInvalidPositionY} from './menu-errors';
2528
})
2629
export class MdMenu {
2730
_showClickCatcher: boolean = false;
31+
private _focusedItemIndex: number = 0;
2832

2933
// config object to be passed into the menu's ngClass
3034
_classList: Object;
@@ -33,6 +37,7 @@ export class MdMenu {
3337
positionY: MenuPositionY = 'below';
3438

3539
@ViewChild(TemplateRef) templateRef: TemplateRef<any>;
40+
@ContentChildren(MdMenuItem) items: QueryList<MdMenuItem>;
3641

3742
constructor(@Attribute('x-position') posX: MenuPositionX,
3843
@Attribute('y-position') posY: MenuPositionY) {
@@ -65,6 +70,68 @@ export class MdMenu {
6570
this._showClickCatcher = bool;
6671
}
6772

73+
/**
74+
* Focus the first item in the menu. This method is used by the menu trigger
75+
* to focus the first item when the menu is opened by the ENTER key.
76+
* TODO: internal
77+
*/
78+
_focusFirstItem() {
79+
this.items.first.focus();
80+
}
81+
82+
// TODO(kara): update this when (keydown.downArrow) testability is fixed
83+
// TODO: internal
84+
_handleKeydown(event: KeyboardEvent): void {
85+
if (event.keyCode === DOWN_ARROW) {
86+
this._focusNextItem();
87+
} else if (event.keyCode === UP_ARROW) {
88+
this._focusPreviousItem();
89+
} else if (event.keyCode === TAB) {
90+
this._emitCloseEvent();
91+
}
92+
}
93+
94+
/**
95+
* This emits a close event to which the trigger is subscribed. When emitted, the
96+
* trigger will close the menu.
97+
*/
98+
private _emitCloseEvent(): void {
99+
this._focusedItemIndex = 0;
100+
this.close.emit(null);
101+
}
102+
103+
private _focusNextItem(): void {
104+
this._updateFocusedItemIndex(1);
105+
this.items.toArray()[this._focusedItemIndex].focus();
106+
}
107+
108+
private _focusPreviousItem(): void {
109+
this._updateFocusedItemIndex(-1);
110+
this.items.toArray()[this._focusedItemIndex].focus();
111+
}
112+
113+
/**
114+
* This method sets focus to the correct menu item, given a list of menu items and the delta
115+
* between the currently focused menu item and the new menu item to be focused. It will
116+
* continue to move down the list until it finds an item that is not disabled, and it will wrap
117+
* if it encounters either end of the menu.
118+
*
119+
* @param delta the desired change in focus index
120+
* @param menuItems the menu items that should be focused
121+
* @private
122+
*/
123+
private _updateFocusedItemIndex(delta: number, menuItems: MdMenuItem[] = this.items.toArray()) {
124+
// when focus would leave menu, wrap to beginning or end
125+
this._focusedItemIndex = (this._focusedItemIndex + delta + this.items.length)
126+
% this.items.length;
127+
128+
// skip all disabled menu items recursively until an active one
129+
// is reached or the menu closes for overreaching bounds
130+
while (menuItems[this._focusedItemIndex].disabled) {
131+
this._updateFocusedItemIndex(delta, menuItems);
132+
}
133+
}
134+
68135
private _setPositionX(pos: MenuPositionX): void {
69136
if ( pos !== 'before' && pos !== 'after') {
70137
throw new MdMenuInvalidPositionX();
@@ -78,8 +145,4 @@ export class MdMenu {
78145
}
79146
this.positionY = pos;
80147
}
81-
82-
private _emitCloseEvent(): void {
83-
this.close.emit(null);
84-
}
85148
}

src/lib/menu/menu-item.ts

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,28 @@
1-
import {Directive, Input, HostBinding} from '@angular/core';
1+
import {Directive, ElementRef, Input, HostBinding, Renderer} from '@angular/core';
22

33
/**
44
* This directive is intended to be used inside an md-menu tag.
55
* It exists mostly to set the role attribute.
66
*/
77
@Directive({
8-
selector: 'button[md-menu-item]',
9-
host: {'role': 'menuitem'}
10-
})
11-
export class MdMenuItem {}
12-
13-
/**
14-
* This directive is intended to be used inside an md-menu tag.
15-
* It sets the role attribute and adds support for the disabled property to anchors.
16-
*/
17-
@Directive({
18-
selector: 'a[md-menu-item]',
8+
selector: '[md-menu-item]',
199
host: {
2010
'role': 'menuitem',
21-
'(click)': 'checkDisabled($event)'
22-
}
11+
'(click)': '_checkDisabled($event)',
12+
'tabindex': '-1'
13+
},
14+
exportAs: 'mdMenuItem'
2315
})
24-
export class MdMenuAnchor {
16+
export class MdMenuItem {
2517
_disabled: boolean;
2618

19+
constructor(private _renderer: Renderer, private _elementRef: ElementRef) {}
20+
21+
focus(): void {
22+
this._renderer.invokeElementMethod(this._elementRef.nativeElement, 'focus');
23+
}
24+
25+
// this is necessary to support anchors
2726
@HostBinding('attr.disabled')
2827
@Input()
2928
get disabled(): boolean {
@@ -39,15 +38,11 @@ export class MdMenuAnchor {
3938
return String(this.disabled);
4039
}
4140

42-
@HostBinding('tabIndex')
43-
get tabIndex(): number {
44-
return this.disabled ? -1 : 0;
45-
}
46-
47-
checkDisabled(event: Event) {
41+
private _checkDisabled(event: Event) {
4842
if (this.disabled) {
4943
event.preventDefault();
5044
event.stopPropagation();
5145
}
5246
}
5347
}
48+

0 commit comments

Comments
 (0)