Skip to content

Commit 8d12d74

Browse files
sgomestraviskaufman
authored andcommitted
feat(menu): Better keyboard accessibility support. (google#4905)
1 parent faa9532 commit 8d12d74

File tree

7 files changed

+476
-38
lines changed

7 files changed

+476
-38
lines changed

demos/simple-menu.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ <h1>MDL Simple Menu</h1>
6262
<button class="toggle">Toggle</button>
6363
<span>Last Selected item: <em id="last-selected">&lt;none selected&gt;</em></span>
6464
</div>
65-
<div class="mdl-simple-menu">
65+
<div class="mdl-simple-menu" tabindex="-1">
6666
<ul class="mdl-simple-menu__items mdl-list" role="menu" aria-hidden="true">
6767
<li class="mdl-list-item" role="menuitem" tabindex="0">Back</li>
6868
<li class="mdl-list-item" role="menuitem" tabindex="0">Forward</li>

packages/mdl-menu/README.md

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ first render.
1414
A simple menu is usually closed, appearing when opened. It is appropriate for any display size.
1515

1616
```html
17-
<div class="mdl-simple-menu">
18-
<ul class="mdl-simple-menu__items mdl-list" role="menu">
17+
<div class="mdl-simple-menu" tabindex="-1">
18+
<ul class="mdl-simple-menu__items mdl-list" role="menu" aria-hidden="true">
1919
<li class="mdl-list-item" role="menuitem" tabindex="0">
2020
A Menu Item
2121
</li>
@@ -25,6 +25,11 @@ A simple menu is usually closed, appearing when opened. It is appropriate for an
2525
</ul>
2626
</div>
2727
```
28+
> Note: adding a `tabindex` of `0` to the menu items places them in the tab order.
29+
Adding a `tabindex` of `-1` to the root element makes it programmatically focusable, without
30+
placing it in the tab order. This allows the menu to be focused on open, so that the next Tab
31+
keypress moves to the first menu item. If you would like the first menu item to be automatically
32+
focused instead, remove `tabindex="-1"` from the root element.
2833

2934
```js
3035
let menu = new mdl.SimpleMenu(document.querySelector('.mdl-simple-menu'));
@@ -61,6 +66,26 @@ menu.open = true;
6166
menu.open = false;
6267
```
6368

69+
It also has two lower level methods, which control the menu directly, by showing (opening) and
70+
hiding (closing) it:
71+
72+
```js
73+
// Show (open) menu.
74+
menu.show();
75+
// Hide (close) menu.
76+
menu.hide();
77+
// Show (open) menu, and focus the menu item at index 1.
78+
menu.show({focusIndex: 1});
79+
```
80+
81+
You can still use the `open` getter property even if showing and hiding directly:
82+
83+
```js
84+
menu.show();
85+
console.log(`Menu is ${menu.open ? 'open' : 'closed'}.`);
86+
```
87+
88+
6489
#### Including in code
6590

6691
##### ES2015
@@ -122,6 +147,8 @@ with the following `detail` data:
122147
| `item` | `HTMLElement` | The DOM element for the selected item |
123148
| `index` | `number` | The index of the selected item |
124149

150+
If the menu is closed with no selection made (for example, if the user hits `Escape` while it's open), a `MDLSimpleMenu:cancel` custom event is emitted instead, with no data attached.
151+
125152
### Using the Foundation Class
126153

127154
MDL Simple Menu ships with an `MDLSimpleMenuFoundation` class that external frameworks and libraries can use to
@@ -140,7 +167,16 @@ The adapter for temporary drawers must provide the following functions, with cor
140167
| `getNumberOfItems() => numbers` | Returns the number of _item_ elements inside the items container. In our vanilla component, we determine this by counting the number of list items whose `role` attribute corresponds to the correct child role of the role present on the menu list element. For example, if the list element has a role of `menu` this queries for all elements that have a role of `menuitem`. |
141168
| `registerInteractionHandler(type: string, handler: EventListener) => void` | Adds an event listener `handler` for event type `type`. |
142169
| `deregisterInteractionHandler(type: string, handler: EventListener) => void` | Removes an event listener `handler` for event type `type`. |
170+
| `registerDocumentClickHandler(handler: EventListener) => void` | Adds an event listener `handler` for event type 'click'. |
171+
| `deregisterDocumentClickHandler(handler: EventListener) => void` | Removes an event listener `handler` for event type 'click'. |
143172
| `getYParamsForItemAtIndex(index: number) => {top: number, height: number}` | Returns an object with the offset top and offset height values for the _item_ element inside the items container at the provided index. Note that this is an index into the list of _item_ elements, and not necessarily every child element of the list. |
144173
| `setTransitionDelayForItemAtIndex(index: number, value: string) => void` | Sets the transition delay on the element inside the items container at the provided index to the provided value. The same notice for `index` applies here as above. |
145174
| `getIndexForEventTarget(target: EventTarget) => number` | Checks to see if the `target` of an event pertains to one of the menu items, and if so returns the index of that item. Returns -1 if the target is not one of the menu items. The same notice for `index` applies here as above. |
146175
| `notifySelected(evtData: {index: number}) => void` | Dispatches an event notifying listeners that a menu item has been selected. The function should accept an `evtData` parameter containing the an object with an `index` property representing the index of the selected item. Implementations may choose to supplement this data with additional data, such as the item itself. |
176+
| `notifyCancel() => void` | Dispatches an event notifying listeners that the menu has been closed with no selection made. |
177+
| `saveFocus() => void` | Stores the currently focused element on the document, for restoring with `restoreFocus`. |
178+
| `restoreFocus() => void` | Restores the previously saved focus state, by making the previously focused element the active focus again. |
179+
| `isFocused() => boolean` | Returns a boolean value indicating whether the root element of the simple menu is focused. |
180+
| `focus() => void` | Focuses the root element of the simple menu. |
181+
| `getFocusedItemIndex() => number` | Returns the index of the currently focused menu item (-1 if none). |
182+
| `focusItemAtIndex(index: number) => void` | Focuses the menu item with the provided index. |

packages/mdl-menu/simple/foundation.js

Lines changed: 125 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -43,24 +43,31 @@ export default class MDLSimpleMenuFoundation extends MDLFoundation {
4343
getNumberOfItems: () => /* number */ 0,
4444
registerInteractionHandler: (/* type: string, handler: EventListener */) => {},
4545
deregisterInteractionHandler: (/* type: string, handler: EventListener */) => {},
46+
registerDocumentClickHandler: (/* handler: EventListener */) => {},
47+
deregisterDocumentClickHandler: (/* handler: EventListener */) => {},
4648
getYParamsForItemAtIndex: (/* index: number */) => /* {top: number, height: number} */ ({}),
4749
setTransitionDelayForItemAtIndex: (/* index: number, value: string */) => {},
4850
getIndexForEventTarget: (/* target: EventTarget */) => /* number */ 0,
49-
notifySelected: (/* evtData: {index: number} */) => {}
51+
notifySelected: (/* evtData: {index: number} */) => {},
52+
notifyCancel: () => {},
53+
saveFocus: () => {},
54+
restoreFocus: () => {},
55+
isFocused: () => /* boolean */ false,
56+
focus: () => {},
57+
getFocusedItemIndex: () => /* number */ -1,
58+
focusItemAtIndex: (/* index: number */) => {}
5059
};
5160
}
5261

5362
constructor(adapter) {
5463
super(Object.assign(MDLSimpleMenuFoundation.defaultAdapter, adapter));
55-
this.keyupHandler_ = evt => {
56-
const {keyCode, key} = evt;
57-
const isEnter = key === 'Enter' || keyCode === 13;
58-
const isSpace = key === 'Space' || keyCode === 32;
59-
if (isEnter || isSpace) {
60-
this.handlePossibleSelected_(evt);
61-
}
62-
};
6364
this.clickHandler_ = evt => this.handlePossibleSelected_(evt);
65+
this.keydownHandler_ = evt => this.handleKeyboardDown_(evt);
66+
this.keyupHandler_ = evt => this.handleKeyboardUp_(evt);
67+
this.documentClickHandler_ = evt => {
68+
this.adapter_.notifyCancel();
69+
this.close();
70+
};
6471
this.isOpen_ = false;
6572
this.startScaleX_ = 0;
6673
this.startScaleY_ = 0;
@@ -88,28 +95,15 @@ export default class MDLSimpleMenuFoundation extends MDLFoundation {
8895

8996
this.adapter_.registerInteractionHandler('click', this.clickHandler_);
9097
this.adapter_.registerInteractionHandler('keyup', this.keyupHandler_);
98+
this.adapter_.registerInteractionHandler('keydown', this.keydownHandler_);
9199
}
92100

93101
destroy() {
94102
clearTimeout(this.selectedTriggerTimerId_);
95103
this.adapter_.deregisterInteractionHandler('click', this.clickHandler_);
96104
this.adapter_.deregisterInteractionHandler('keyup', this.keyupHandler_);
97-
}
98-
99-
handlePossibleSelected_(evt) {
100-
const targetIndex = this.adapter_.getIndexForEventTarget(evt.target);
101-
if (targetIndex < 0) {
102-
return;
103-
}
104-
// Debounce multiple selections
105-
if (this.selectedTriggerTimerId_) {
106-
return;
107-
}
108-
this.selectedTriggerTimerId_ = setTimeout(() => {
109-
this.selectedTriggerTimerId_ = 0;
110-
this.close();
111-
this.adapter_.notifySelected({index: targetIndex});
112-
}, numbers.SELECTED_TRIGGER_DELAY);
105+
this.adapter_.deregisterInteractionHandler('keydown', this.keydownHandler_);
106+
this.adapter_.deregisterDocumentClickHandler(this.documentClickHandler_);
113107
}
114108

115109
// Calculate transition delays for individual menu items, so that they fade in one at a time.
@@ -206,27 +200,131 @@ export default class MDLSimpleMenuFoundation extends MDLFoundation {
206200
}
207201
}
208202

209-
// Open the menu.
210-
open() {
203+
focusOnOpen_(focusIndex) {
204+
if (focusIndex === null) {
205+
// First, try focusing the menu.
206+
this.adapter_.focus();
207+
// If that doesn't work, focus first item instead.
208+
if (!this.adapter_.isFocused()) {
209+
this.adapter_.focusItemAtIndex(0);
210+
}
211+
} else {
212+
this.adapter_.focusItemAtIndex(focusIndex);
213+
}
214+
}
215+
216+
// Handle keys that we want to repeat on hold (tab and arrows).
217+
handleKeyboardDown_(evt) {
218+
// Do nothing if Alt, Ctrl or Meta are pressed.
219+
if (evt.altKey || evt.ctrlKey || evt.metaKey) {
220+
return true;
221+
}
222+
223+
const {keyCode, key, shiftKey} = evt;
224+
const isTab = key === 'Tab' || keyCode === 9;
225+
const isArrowUp = key === 'ArrowUp' || keyCode === 38;
226+
const isArrowDown = key === 'ArrowDown' || keyCode === 40;
227+
228+
const focusedItemIndex = this.adapter_.getFocusedItemIndex();
229+
const lastItemIndex = this.adapter_.getNumberOfItems() - 1;
230+
231+
if (shiftKey && isTab && focusedItemIndex === 0) {
232+
this.adapter_.focusItemAtIndex(lastItemIndex);
233+
evt.preventDefault();
234+
return false;
235+
}
236+
237+
if (!shiftKey && isTab && focusedItemIndex === lastItemIndex) {
238+
this.adapter_.focusItemAtIndex(0);
239+
evt.preventDefault();
240+
return false;
241+
}
242+
243+
if (isArrowUp) {
244+
if (focusedItemIndex === 0 || this.adapter_.isFocused()) {
245+
this.adapter_.focusItemAtIndex(lastItemIndex);
246+
} else {
247+
this.adapter_.focusItemAtIndex(focusedItemIndex - 1);
248+
}
249+
}
250+
251+
if (isArrowDown) {
252+
if (focusedItemIndex === lastItemIndex || this.adapter_.isFocused()) {
253+
this.adapter_.focusItemAtIndex(0);
254+
} else {
255+
this.adapter_.focusItemAtIndex(focusedItemIndex + 1);
256+
}
257+
}
258+
259+
return true;
260+
}
261+
262+
// Handle keys that we don't want to repeat on hold (Enter, Space, Escape).
263+
handleKeyboardUp_(evt) {
264+
// Do nothing if Alt, Ctrl or Meta are pressed.
265+
if (evt.altKey || evt.ctrlKey || evt.metaKey) {
266+
return true;
267+
}
268+
269+
const {keyCode, key} = evt;
270+
const isEnter = key === 'Enter' || keyCode === 13;
271+
const isSpace = key === 'Space' || keyCode === 32;
272+
const isEscape = key === 'Escape' || keyCode === 27;
273+
274+
if (isEnter || isSpace) {
275+
this.handlePossibleSelected_(evt);
276+
}
277+
278+
if (isEscape) {
279+
this.adapter_.notifyCancel();
280+
this.close();
281+
}
282+
283+
return true;
284+
}
285+
286+
handlePossibleSelected_(evt) {
287+
const targetIndex = this.adapter_.getIndexForEventTarget(evt.target);
288+
if (targetIndex < 0) {
289+
return;
290+
}
291+
// Debounce multiple selections
292+
if (this.selectedTriggerTimerId_) {
293+
return;
294+
}
295+
this.selectedTriggerTimerId_ = setTimeout(() => {
296+
this.selectedTriggerTimerId_ = 0;
297+
this.close();
298+
this.adapter_.notifySelected({index: targetIndex});
299+
}, numbers.SELECTED_TRIGGER_DELAY);
300+
}
301+
302+
// Open the menu. Optionally focus on provided item.
303+
open({focusIndex = null} = {}) {
304+
this.adapter_.saveFocus();
211305
this.adapter_.addClass(MDLSimpleMenuFoundation.cssClasses.ANIMATING);
212306
requestAnimationFrame(() => {
213307
this.dimensions_ = this.adapter_.getInnerDimensions();
214308
this.applyTransitionDelays_();
215309
this.animateMenu_();
216310
this.adapter_.addClass(MDLSimpleMenuFoundation.cssClasses.OPEN);
311+
this.focusOnOpen_(focusIndex);
312+
this.adapter_.registerDocumentClickHandler(this.documentClickHandler_);
217313
});
218314
this.isOpen_ = true;
219315
}
220316

221317
// Close the menu.
222318
close() {
319+
this.adapter_.deregisterDocumentClickHandler(this.documentClickHandler_);
223320
this.adapter_.addClass(MDLSimpleMenuFoundation.cssClasses.ANIMATING);
224321
requestAnimationFrame(() => {
225322
this.removeTransitionDelays_();
226323
this.animateMenu_();
227324
this.adapter_.removeClass(MDLSimpleMenuFoundation.cssClasses.OPEN);
228325
});
229326
this.isOpen_ = false;
327+
this.adapter_.restoreFocus();
230328
}
231329

232330
isOpen() {

packages/mdl-menu/simple/index.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,14 @@ export class MDLSimpleMenu extends MDLComponent {
3838
}
3939
}
4040

41+
show({focusIndex = null} = {}) {
42+
this.foundation_.open({focusIndex: focusIndex});
43+
}
44+
45+
hide() {
46+
this.foundation_.close();
47+
}
48+
4149
/* Return the item container element inside the component. */
4250
get itemsContainer_() {
4351
return this.root_.querySelector(MDLSimpleMenuFoundation.strings.ITEMS_SELECTOR);
@@ -72,6 +80,8 @@ export class MDLSimpleMenu extends MDLComponent {
7280
getNumberOfItems: () => this.items.length,
7381
registerInteractionHandler: (type, handler) => this.root_.addEventListener(type, handler),
7482
deregisterInteractionHandler: (type, handler) => this.root_.removeEventListener(type, handler),
83+
registerDocumentClickHandler: handler => document.addEventListener('click', handler),
84+
deregisterDocumentClickHandler: handler => document.removeEventListener('click', handler),
7585
getYParamsForItemAtIndex: index => {
7686
const {offsetTop: top, offsetHeight: height} = this.items[index];
7787
return {top, height};
@@ -82,7 +92,20 @@ export class MDLSimpleMenu extends MDLComponent {
8292
notifySelected: evtData => this.emit('MDLSimpleMenu:selected', {
8393
index: evtData.index,
8494
item: this.items[evtData.index]
85-
})
95+
}),
96+
notifyCancel: () => this.emit('MDLSimpleMenu:cancel'),
97+
saveFocus: () => {
98+
this.previousFocus_ = document.activeElement;
99+
},
100+
restoreFocus: () => {
101+
if (this.previousFocus_) {
102+
this.previousFocus_.focus();
103+
}
104+
},
105+
isFocused: () => document.activeElement === this.root_,
106+
focus: () => this.root_.focus(),
107+
getFocusedItemIndex: () => this.items.indexOf(document.activeElement),
108+
focusItemAtIndex: index => this.items[index].focus()
86109
});
87110
}
88111

packages/mdl-menu/simple/mdl-simple-menu.scss

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ $mdl-simple-menu-item-offset: 10px;
4848
background-color: #424242;
4949
}
5050

51+
&:focus {
52+
outline: none;
53+
}
54+
5155
&--open {
5256
display: inline-block;
5357
transform: scale(1);

0 commit comments

Comments
 (0)