Skip to content
This repository was archived by the owner on Jan 13, 2025. It is now read-only.

feat(menu): Added new API to manually set focus when menu is opened #4468

Merged
merged 11 commits into from
Mar 18, 2019
Merged
19 changes: 19 additions & 0 deletions packages/mdc-menu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,18 @@ Mixin | Description

> See [Menu Surface](../mdc-menu-surface/README.md#sass-mixins) and [List](../mdc-list/README.md#sass-mixins) documentation for additional style customization options.

### Accessibility

Please see [Menu Button](https://www.w3.org/TR/wai-aria-practices/#menubutton) WAI-ARIA practices article for details on recommended Roles, States, and Properties for menu button (button that opens a menu).

With focus on the menu button:

* <kbd>Enter</kbd>, <kbd>Space</kbd> & <kbd>Down Arrow</kbd> opens the menu and places focus on the first menu item.
* <kbd>Up Arrow</kbd> opens the menu and moves focus to the last menu item.
* The focus is set to menu root element when clicked or touched. Menu handles the keyboard handling once it is opened and focused on root element.

Use `setDefaultFocusItemIndex` method to set the index of the menu item that will be focused every time the menu opens. Set it to `numbers.FOCUS_ROOT_INDEX` to focus on menu root element.

## `MDCMenu` Properties and Methods

See [Importing the JS component](../../docs/importing-js.md) for more information on how to import JavaScript.
Expand All @@ -212,6 +224,7 @@ Method Signature | Description
`setAnchorElement(element: Element) => void` | Proxies to the menu surface's `setAnchorElement(element)` method.
`getOptionByIndex(index: number) => Element \| null` | Returns the list item at the `index` specified.
`getDefaultFoundation() => MDCMenuFoundation` | Returns the foundation.
`setDefaultFocusItemIndex(index: number) => void` | Sets the index of the menu item that will be focused every time the menu opens. Pass `numbers.FOCUS_ROOT_INDEX` to indicate that the root menu element, rather than a specific list item, should receive focus when the menu opens (this is the default behavior).

> See [Menu Surface](../mdc-menu-surface/README.md) and [List](../mdc-list/README.md) documentation for more information on proxied methods and properties.

Expand All @@ -233,13 +246,19 @@ Method Signature | Description
`getParentElement(element: Element) => Element \| null` | Returns the `.parentElement` element of the `element` provided.
`getSelectedElementIndex(element: Element) => number` | Returns the `index` value of the element within the selection group provided, `element` that contains the `mdc-menu-item--selected` class.
`notifySelected(index: number) => void` | Emits a `MDCMenu:selected` event for the element at the `index` specified.
`getMenuItemCount() => number` | Returns the menu item count.
`focusItemAtIndex(index: number)` | Focuses the menu item at given index.
`isRootFocused() => boolean` | Returns true if menu root element has focus.
`focusRoot() => void` | Focuses the menu root element.

### `MDCMenuFoundation`

Method Signature | Description
--- | ---
`handleKeydown(evt: Event) => void` | Event handler for the `keydown` events within the menu.
`handleItemAction(listItem: Element) => void` | Event handler for list's action event.
`handleMenuSurfaceOpened() => void` | Event handler for menu surface's opened event.
`setDefaultFocusItemIndex(index: number) => void` | Sets the index of the menu item that will be focused every time the menu opens. Pass `numbers.FOCUS_ROOT_INDEX` to indicate that the root menu element, rather than a specific list item, should receive focus when the menu opens (this is the default behavior).

### Events

Expand Down
15 changes: 15 additions & 0 deletions packages/mdc-menu/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,19 @@ export interface MDCMenuAdapter {
* Emit an event when a menu item is selected.
*/
notifySelected(evtData: MDCMenuItemEventDetail): void;

/** @return Returns the menu item count. */
getMenuItemCount(): number;

/**
* Focuses the menu item at given index.
* @param index Index of the menu item that will be focused every time the menu opens.
*/
focusItemAtIndex(index: number): void;

/** @return Returns true if menu root element has focus. */
isRootFocused(): boolean;

/** Focuses the menu root element. */
focusRoot(): void;
}
31 changes: 20 additions & 11 deletions packages/mdc-menu/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export class MDCMenu extends MDCComponent<MDCMenuFoundation> {

private handleKeydown_!: SpecificEventListener<'keydown'>; // assigned in initialSyncWithDOM()
private handleItemAction_!: CustomEventListener<MDCListActionEvent>; // assigned in initialSyncWithDOM()
private afterOpenedCallback_!: EventListener; // assigned in initialSyncWithDOM()
private handleMenuSurfaceOpened_!: EventListener; // assigned in initialSyncWithDOM()

initialize(
menuSurfaceFactory: MDCMenuSurfaceFactory = (el) => new MDCMenuSurface(el),
Expand All @@ -69,9 +69,9 @@ export class MDCMenu extends MDCComponent<MDCMenuFoundation> {

this.handleKeydown_ = (evt) => this.foundation_.handleKeydown(evt);
this.handleItemAction_ = (evt) => this.foundation_.handleItemAction(this.items[evt.detail.index]);
this.afterOpenedCallback_ = () => this.handleAfterOpened_();
this.handleMenuSurfaceOpened_ = () => this.foundation_.handleMenuSurfaceOpened();

this.menuSurface_.listen(MDCMenuSurfaceFoundation.strings.OPENED_EVENT, this.afterOpenedCallback_);
this.menuSurface_.listen(MDCMenuSurfaceFoundation.strings.OPENED_EVENT, this.handleMenuSurfaceOpened_);
this.listen('keydown', this.handleKeydown_);
this.listen(MDCListFoundation.strings.ACTION_EVENT, this.handleItemAction_);
}
Expand All @@ -82,7 +82,7 @@ export class MDCMenu extends MDCComponent<MDCMenuFoundation> {
}

this.menuSurface_.destroy();
this.menuSurface_.unlisten(MDCMenuSurfaceFoundation.strings.OPENED_EVENT, this.afterOpenedCallback_);
this.menuSurface_.unlisten(MDCMenuSurfaceFoundation.strings.OPENED_EVENT, this.handleMenuSurfaceOpened_);
this.unlisten('keydown', this.handleKeydown_);
this.unlisten(MDCListFoundation.strings.ACTION_EVENT, this.handleItemAction_);
super.destroy();
Expand Down Expand Up @@ -119,6 +119,18 @@ export class MDCMenu extends MDCComponent<MDCMenuFoundation> {
this.menuSurface_.quickOpen = quickOpen;
}

/**
* Sets the index of the menu item that will be focused every time the menu opens.
* Pass {@link numbers.FOCUS_ROOT_INDEX} to indicate that the root menu element,
* rather than a specific list item, should receive focus when the menu opens
* (this is the default behavior).
* @param index Index of the menu item to focus when the menu opens,
* or {@link numbers.FOCUS_ROOT_INDEX} for the root menu element.
*/
setDefaultFocusItemIndex(index: number) {
this.foundation_.setDefaultFocusItemIndex(index);
}

/**
* @param corner Default anchor corner alignment of top-left menu corner.
*/
Expand Down Expand Up @@ -199,15 +211,12 @@ export class MDCMenu extends MDCComponent<MDCMenuFoundation> {
index: evtData.index,
item: this.items[evtData.index],
}),
getMenuItemCount: () => this.items.length,
focusItemAtIndex: (index) => (this.items[index] as HTMLElement).focus(),
isRootFocused: () => document.activeElement === this.root_,
focusRoot: () => (this.root_ as HTMLElement).focus(),
};
// tslint:enable:object-literal-sort-keys
return new MDCMenuFoundation(adapter);
}

private handleAfterOpened_() {
const list = this.items;
if (list.length > 0) {
(list[0] as HTMLElement).focus();
}
}
}
6 changes: 5 additions & 1 deletion packages/mdc-menu/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,8 @@ const strings = {
SELECTED_EVENT: 'MDCMenu:selected',
};

export {cssClasses, strings};
const numbers = {
FOCUS_ROOT_INDEX: -1,
};

export {cssClasses, strings, numbers};
51 changes: 50 additions & 1 deletion packages/mdc-menu/foundation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {MDCFoundation} from '@material/base/foundation';
import {MDCListFoundation} from '@material/list/foundation';
import {MDCMenuSurfaceFoundation} from '@material/menu-surface/foundation';
import {MDCMenuAdapter} from './adapter';
import {cssClasses, strings} from './constants';
import {cssClasses, numbers, strings} from './constants';

export class MDCMenuFoundation extends MDCFoundation<MDCMenuAdapter> {
static get cssClasses() {
Expand All @@ -36,7 +36,12 @@ export class MDCMenuFoundation extends MDCFoundation<MDCMenuAdapter> {
return strings;
}

static get numbers() {
return numbers;
}

private closeAnimationEndTimerId_ = 0;
private defaultFocusItemIndex_ = numbers.FOCUS_ROOT_INDEX;

/**
* @see {@link MDCMenuAdapter} for typing information on parameters and return types.
Expand All @@ -54,6 +59,10 @@ export class MDCMenuFoundation extends MDCFoundation<MDCMenuAdapter> {
getParentElement: () => null,
getSelectedElementIndex: () => -1,
notifySelected: () => undefined,
getMenuItemCount: () => 0,
focusItemAtIndex: () => undefined,
isRootFocused: () => false,
focusRoot: () => undefined,
};
// tslint:enable:object-literal-sort-keys
}
Expand All @@ -77,6 +86,19 @@ export class MDCMenuFoundation extends MDCFoundation<MDCMenuAdapter> {
if (isTab) {
this.adapter_.closeSurface();
}

const arrowUp = evt.key === 'ArrowUp' || evt.keyCode === 38;
const arrowDown = evt.key === 'ArrowDown' || evt.keyCode === 40;

if (!this.adapter_.isRootFocused()) {
return;
}

if (arrowUp || arrowDown) {
evt.preventDefault();
const focusItemIndex = arrowDown ? 0 : (this.adapter_.getMenuItemCount() - 1);
this.focusItemAtIndex_(focusItemIndex);
}
}

handleItemAction(listItem: Element) {
Expand All @@ -97,6 +119,33 @@ export class MDCMenuFoundation extends MDCFoundation<MDCMenuAdapter> {
}, MDCMenuSurfaceFoundation.numbers.TRANSITION_CLOSE_DURATION);
}

handleMenuSurfaceOpened() {
this.focusItemAtIndex_(this.defaultFocusItemIndex_);
}

/**
* Sets the focus item index where the menu should focus on open. Focuses
* the menu root element by default.
*/
setDefaultFocusItemIndex(index: number) {
const isIndexInRange = index >= 0 && index < this.adapter_.getMenuItemCount();

if (index === numbers.FOCUS_ROOT_INDEX || isIndexInRange) {
this.defaultFocusItemIndex_ = index;
} else {
throw new Error(`MDCMenuFoundation: Expected index to be in range or ${numbers.FOCUS_ROOT_INDEX} ` +
`but got: ${index}`);
}
}

private focusItemAtIndex_(index: number) {
if (index === numbers.FOCUS_ROOT_INDEX) {
this.adapter_.focusRoot();
} else {
this.adapter_.focusItemAtIndex(index);
}
}

/**
* Handles toggling the selected classes in a selection group when a selection is made.
*/
Expand Down
11 changes: 7 additions & 4 deletions packages/mdc-select/component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,14 @@ export class MDCSelect extends MDCComponent<MDCSelectFoundation> implements MDCR
this.handleKeydown_ = (evt) => this.foundation_.handleKeydown(evt);
this.handleMenuSelected_ = (evtData) => this.selectedIndex = evtData.detail.index;
this.handleMenuOpened_ = () => {
// Menu should open to the last selected element.
if (this.selectedIndex >= 0) {
const selectedItemEl = this.menu_!.items[this.selectedIndex] as HTMLElement;
selectedItemEl.focus();
if (this.menu_!.items.length === 0) {
return;
}

// Menu should open to the last selected element, should open to first menu item otherwise.
const focusItemIndex = this.selectedIndex >= 0 ? this.selectedIndex : 0;
const focusItemEl = this.menu_!.items[focusItemIndex] as HTMLElement;
focusItemEl.focus();
};
this.handleMenuClosed_ = () => {
// isMenuOpen_ is used to track the state of the menu opening or closing since the menu.open function
Expand Down
24 changes: 12 additions & 12 deletions test/screenshot/golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -760,27 +760,27 @@
}
},
"spec/mdc-menu/classes/baseline.html": {
"public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/01/31/16_07_46_502/spec/mdc-menu/classes/baseline.html?utm_source=golden_json",
"public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/03/15/18_35_08_782/spec/mdc-menu/classes/baseline.html?utm_source=golden_json",
"screenshots": {
"desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/01/31/16_07_46_502/spec/mdc-menu/classes/baseline.html.windows_chrome_71.png",
"desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/01/31/16_07_46_502/spec/mdc-menu/classes/baseline.html.windows_firefox_64.png",
"desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/01/31/16_07_46_502/spec/mdc-menu/classes/baseline.html.windows_ie_11.png"
"desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/03/15/18_35_08_782/spec/mdc-menu/classes/baseline.html.windows_chrome_72.png",
"desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/03/15/18_35_08_782/spec/mdc-menu/classes/baseline.html.windows_firefox_65.png",
"desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/03/15/18_35_08_782/spec/mdc-menu/classes/baseline.html.windows_ie_11.png"
}
},
"spec/mdc-menu/classes/bottom-anchored.html": {
"public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/02/27/23_03_26_073/spec/mdc-menu/classes/bottom-anchored.html?utm_source=golden_json",
"public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/03/15/18_35_08_782/spec/mdc-menu/classes/bottom-anchored.html?utm_source=golden_json",
"screenshots": {
"desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/02/27/23_03_26_073/spec/mdc-menu/classes/bottom-anchored.html.windows_chrome_72.png",
"desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/02/27/23_03_26_073/spec/mdc-menu/classes/bottom-anchored.html.windows_firefox_65.png",
"desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/02/27/23_03_26_073/spec/mdc-menu/classes/bottom-anchored.html.windows_ie_11.png"
"desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/03/15/18_35_08_782/spec/mdc-menu/classes/bottom-anchored.html.windows_chrome_72.png",
"desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/03/15/18_35_08_782/spec/mdc-menu/classes/bottom-anchored.html.windows_firefox_65.png",
"desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/03/15/18_35_08_782/spec/mdc-menu/classes/bottom-anchored.html.windows_ie_11.png"
}
},
"spec/mdc-menu/classes/menu-selection-group.html": {
"public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2019/02/11/21_55_33_374/spec/mdc-menu/classes/menu-selection-group.html?utm_source=golden_json",
"public_url": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/03/15/18_35_08_782/spec/mdc-menu/classes/menu-selection-group.html?utm_source=golden_json",
"screenshots": {
"desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/01/31/16_07_46_502/spec/mdc-menu/classes/menu-selection-group.html.windows_chrome_71.png",
"desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/advorak/2019/02/11/21_55_33_374/spec/mdc-menu/classes/menu-selection-group.html.windows_firefox_65.png",
"desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/01/31/16_07_46_502/spec/mdc-menu/classes/menu-selection-group.html.windows_ie_11.png"
"desktop_windows_chrome@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/03/15/18_35_08_782/spec/mdc-menu/classes/menu-selection-group.html.windows_chrome_72.png",
"desktop_windows_firefox@latest": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/03/15/18_35_08_782/spec/mdc-menu/classes/menu-selection-group.html.windows_firefox_65.png",
"desktop_windows_ie@11": "https://storage.googleapis.com/mdc-web-screenshot-tests/travis/2019/03/15/18_35_08_782/spec/mdc-menu/classes/menu-selection-group.html.windows_ie_11.png"
}
},
"spec/mdc-menu/issues/4025.html": {
Expand Down
26 changes: 26 additions & 0 deletions test/screenshot/spec/mdc-menu/fixture.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,32 @@ window.mdc.testFixture.fontsLoaded.then(() => {
menu.open = true;

buttonEl.addEventListener('click', () => {
menu.setDefaultFocusItemIndex(mdc.menu.MDCMenuFoundation.numbers.FOCUS_ROOT_INDEX);
menu.open = !menu.open;
});

buttonEl.addEventListener('keydown', (evt) => {
const arrowUp = evt.key === 'ArrowUp' || evt.keyCode === 38;
const arrowDown = evt.key === 'ArrowDown' || evt.keyCode === 40;
const isEnter = evt.key === 'Enter' || evt.keyCode === 13;
const isSpace = evt.key === 'Space' || evt.keyCode === 32;

if (arrowUp || arrowDown || isEnter || isSpace) {
evt.preventDefault();
} else {
return;
}

if (isSpace) {
menu.setDefaultFocusItemIndex(0);
} else if (isEnter) {
menu.setDefaultFocusItemIndex(0);
} else if (arrowDown) {
menu.setDefaultFocusItemIndex(0);
} else if (arrowUp) {
menu.setDefaultFocusItemIndex(menu.items.length - 1);
}

menu.open = !menu.open;
});

Expand Down
Loading