Skip to content

Commit 1896303

Browse files
committed
feat(menu): add keyboard events and accessibility
1 parent ae5717c commit 1896303

File tree

13 files changed

+312
-47
lines changed

13 files changed

+312
-47
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: 134 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,137 @@ 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 focus subsequent items when tab is pressed', () => {
100+
page.pressKey(protractor.Key.ENTER);
101+
page.pressKey(protractor.Key.TAB);
102+
page.expectFocusOn(page.items(1));
103+
});
104+
105+
it('should focus previous items when shift-tab is pressed', () => {
106+
page.pressKey(protractor.Key.ENTER);
107+
page.pressKey(protractor.Key.TAB);
108+
// need a protractor "chord" to hit shift-tab simultaneously
109+
page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
110+
page.expectFocusOn(page.items(0));
111+
});
112+
113+
it('should handle a mix of tabs and arrow presses', () => {
114+
page.pressKey(protractor.Key.ENTER);
115+
page.pressKey(protractor.Key.TAB);
116+
page.pressKey(protractor.Key.UP);
117+
page.expectFocusOn(page.items(0));
118+
119+
page.pressKey(protractor.Key.DOWN);
120+
page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
121+
page.expectFocusOn(page.items(0));
122+
});
123+
124+
it('should skip disabled items using arrow keys', () => {
125+
page.pressKey(protractor.Key.ENTER);
126+
page.pressKey(protractor.Key.DOWN);
127+
page.pressKey(protractor.Key.DOWN);
128+
page.expectFocusOn(page.items(3));
129+
130+
page.pressKey(protractor.Key.UP);
131+
page.expectFocusOn(page.items(1));
132+
});
133+
134+
it('should skip disabled items using tabs', () => {
135+
page.pressKey(protractor.Key.ENTER);
136+
page.pressKey(protractor.Key.TAB);
137+
page.pressKey(protractor.Key.TAB);
138+
page.expectFocusOn(page.items(3));
139+
140+
page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
141+
page.expectFocusOn(page.items(1));
142+
});
143+
144+
it('should close the menu when tabbing past items', () => {
145+
page.pressKey(protractor.Key.ENTER);
146+
page.pressKey(protractor.Key.TAB);
147+
page.pressKey(protractor.Key.TAB);
148+
page.pressKey(protractor.Key.TAB);
149+
page.expectMenuPresent(false);
150+
151+
page.start().click();
152+
page.pressKey(protractor.Key.TAB);
153+
page.pressKey(protractor.Key.ENTER);
154+
page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
155+
page.expectMenuPresent(false);
156+
});
157+
158+
it('should close the menu when arrow keying past items', () => {
159+
page.pressKey(protractor.Key.ENTER);
160+
page.pressKey(protractor.Key.DOWN);
161+
page.pressKey(protractor.Key.DOWN);
162+
page.pressKey(protractor.Key.DOWN);
163+
page.expectMenuPresent(false);
164+
165+
page.start().click();
166+
page.pressKey(protractor.Key.TAB);
167+
page.pressKey(protractor.Key.ENTER);
168+
page.pressKey(protractor.Key.UP);
169+
page.expectMenuPresent(false);
170+
});
171+
172+
it('should focus before and after trigger when tabbing past items', () => {
173+
page.pressKey(protractor.Key.ENTER);
174+
page.pressKey(protractor.Key.TAB);
175+
page.pressKey(protractor.Key.TAB);
176+
page.pressKey(protractor.Key.TAB);
177+
page.expectFocusOn(page.triggerTwo());
178+
179+
// navigate back to trigger
180+
page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
181+
page.pressKey(protractor.Key.ENTER);
182+
183+
page.pressKey(protractor.Key.chord(protractor.Key.SHIFT, protractor.Key.TAB));
184+
page.expectFocusOn(page.start());
185+
});
186+
187+
it('should focus on trigger when arrow keying past items', () => {
188+
page.pressKey(protractor.Key.ENTER);
189+
page.pressKey(protractor.Key.DOWN);
190+
page.pressKey(protractor.Key.DOWN);
191+
page.pressKey(protractor.Key.DOWN);
192+
page.expectFocusOn(page.trigger());
193+
194+
page.pressKey(protractor.Key.ENTER);
195+
page.pressKey(protractor.Key.UP);
196+
page.expectFocusOn(page.trigger());
197+
});
198+
});
199+
69200
describe('position - ', () => {
70201

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

src/components/menu/README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,12 @@ Output:
129129
### Accessibility
130130

131131
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.
132+
also adds `aria-hasPopup="true"` to the trigger element.
133+
134+
#### Keyboard events:
135+
- <kbd>DOWN_ARROW</kbd> or <kbd>TAB</kbd>: Focus next menu item
136+
- <kbd>UP_ARROW</kbd> or <kbd>SHIFT_TAB</kbd>: Focus previous menu item
137+
- <kbd>ENTER</kbd>: Select focused item
133138

134139
### Menu attributes
135140

@@ -160,7 +165,6 @@ also adds `aria-hasPopup="true"` to the trigger element.
160165

161166
### TODO
162167

163-
- Keyboard events: up arrow, down arrow, enter
164168
- `prevent-close` option, to turn off automatic menu close when clicking outside the menu
165169
- Custom offset support
166170

src/components/menu/menu-directive.ts

Lines changed: 72 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,72 @@ 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() { this.items.first.focus(); }
79+
80+
// TODO(kara): update this when (keydown.downArrow) testability is fixed
81+
// TODO: internal
82+
_handleKeydown(event: KeyboardEvent): void {
83+
if (event.keyCode === DOWN_ARROW) {
84+
this._focusNextItem();
85+
} else if (event.keyCode === UP_ARROW) {
86+
this._focusPreviousItem();
87+
} else if (event.keyCode === TAB) {
88+
this._handleTabKeypress(event.shiftKey);
89+
}
90+
}
91+
92+
/**
93+
* When the tab key is pressed (changing focus natively), this function syncs up
94+
* the focusedItemIndex to match the currently focused item. If the shift key is
95+
* also pressed, we know that the focus has changed to the previous tabindex. If not,
96+
* focus has changed to the next tabindex.
97+
*/
98+
private _handleTabKeypress(shiftPressed: boolean): void {
99+
shiftPressed ? this._updateFocusedItemIndex(-1) : this._updateFocusedItemIndex(1);
100+
}
101+
102+
/**
103+
* This emits a close event to which the trigger is subscribed. When emitted, the
104+
* trigger will close the menu.
105+
*/
106+
private _emitCloseEvent(): void {
107+
this._focusedItemIndex = 0;
108+
this.close.emit(null);
109+
}
110+
111+
// When focus would shift past the start or end of the menu, close the menu.
112+
private _closeIfFocusLeavesMenu(): void {
113+
if (this._focusedItemIndex >= this.items.length || this._focusedItemIndex < 0) {
114+
this._emitCloseEvent();
115+
}
116+
}
117+
118+
private _focusNextItem(): void {
119+
this._updateFocusedItemIndex(1);
120+
this.items.toArray()[this._focusedItemIndex].focus();
121+
}
122+
123+
private _focusPreviousItem(): void {
124+
this._updateFocusedItemIndex(-1);
125+
this.items.toArray()[this._focusedItemIndex].focus();
126+
}
127+
128+
private _updateFocusedItemIndex(delta: number, itemArray: MdMenuItem[] = this.items.toArray()) {
129+
this._focusedItemIndex += delta;
130+
this._closeIfFocusLeavesMenu();
131+
132+
// skip all disabled menu items recursively until an active one
133+
// is reached or the menu closes for overreaching bounds
134+
while (itemArray[this._focusedItemIndex].disabled) {
135+
this._updateFocusedItemIndex(delta, itemArray);
136+
}
137+
}
138+
68139
private _setPositionX(pos: MenuPositionX): void {
69140
if ( pos !== 'before' && pos !== 'after') {
70141
throw new MdMenuInvalidPositionX();
@@ -78,8 +149,4 @@ export class MdMenu {
78149
}
79150
this.positionY = pos;
80151
}
81-
82-
private _emitCloseEvent(): void {
83-
this.close.emit(null);
84-
}
85152
}

src/components/menu/menu-item.ts

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,27 @@
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+
},
13+
exportAs: 'mdMenuItem'
2314
})
24-
export class MdMenuAnchor {
15+
export class MdMenuItem {
2516
_disabled: boolean;
2617

18+
constructor(private _renderer: Renderer, private _elementRef: ElementRef) {}
19+
20+
focus(): void {
21+
this._renderer.invokeElementMethod(this._elementRef.nativeElement, 'focus');
22+
}
23+
24+
// this is necessary to support anchors
2725
@HostBinding('attr.disabled')
2826
@Input()
2927
get disabled(): boolean {
@@ -38,16 +36,16 @@ export class MdMenuAnchor {
3836
get isAriaDisabled(): string {
3937
return String(this.disabled);
4038
}
41-
4239
@HostBinding('tabIndex')
4340
get tabIndex(): number {
4441
return this.disabled ? -1 : 0;
4542
}
4643

47-
checkDisabled(event: Event) {
44+
private _checkDisabled(event: Event) {
4845
if (this.disabled) {
4946
event.preventDefault();
5047
event.stopPropagation();
5148
}
5249
}
5350
}
51+

0 commit comments

Comments
 (0)