diff --git a/packages/mdc-menu/README.md b/packages/mdc-menu/README.md
index 9b5584c7333..35243e609a3 100644
--- a/packages/mdc-menu/README.md
+++ b/packages/mdc-menu/README.md
@@ -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:
+
+ * Enter, Space & Down Arrow opens the menu and places focus on the first menu item.
+ * Up Arrow 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.
@@ -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.
@@ -233,6 +246,10 @@ 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`
@@ -240,6 +257,8 @@ 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
diff --git a/packages/mdc-menu/adapter.ts b/packages/mdc-menu/adapter.ts
index 0dce8d260c7..d867f5774e1 100644
--- a/packages/mdc-menu/adapter.ts
+++ b/packages/mdc-menu/adapter.ts
@@ -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;
}
diff --git a/packages/mdc-menu/component.ts b/packages/mdc-menu/component.ts
index 3e6ce143938..4e351f59ca8 100644
--- a/packages/mdc-menu/component.ts
+++ b/packages/mdc-menu/component.ts
@@ -47,7 +47,7 @@ export class MDCMenu extends MDCComponent {
private handleKeydown_!: SpecificEventListener<'keydown'>; // assigned in initialSyncWithDOM()
private handleItemAction_!: CustomEventListener; // assigned in initialSyncWithDOM()
- private afterOpenedCallback_!: EventListener; // assigned in initialSyncWithDOM()
+ private handleMenuSurfaceOpened_!: EventListener; // assigned in initialSyncWithDOM()
initialize(
menuSurfaceFactory: MDCMenuSurfaceFactory = (el) => new MDCMenuSurface(el),
@@ -69,9 +69,9 @@ export class MDCMenu extends MDCComponent {
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_);
}
@@ -82,7 +82,7 @@ export class MDCMenu extends MDCComponent {
}
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();
@@ -119,6 +119,18 @@ export class MDCMenu extends MDCComponent {
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.
*/
@@ -199,15 +211,12 @@ export class MDCMenu extends MDCComponent {
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();
- }
- }
}
diff --git a/packages/mdc-menu/constants.ts b/packages/mdc-menu/constants.ts
index f9375a359ef..9c14788e35c 100644
--- a/packages/mdc-menu/constants.ts
+++ b/packages/mdc-menu/constants.ts
@@ -34,4 +34,8 @@ const strings = {
SELECTED_EVENT: 'MDCMenu:selected',
};
-export {cssClasses, strings};
+const numbers = {
+ FOCUS_ROOT_INDEX: -1,
+};
+
+export {cssClasses, strings, numbers};
diff --git a/packages/mdc-menu/foundation.ts b/packages/mdc-menu/foundation.ts
index 6e190bfd6bd..910e3d644c0 100644
--- a/packages/mdc-menu/foundation.ts
+++ b/packages/mdc-menu/foundation.ts
@@ -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 {
static get cssClasses() {
@@ -36,7 +36,12 @@ export class MDCMenuFoundation extends MDCFoundation {
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.
@@ -54,6 +59,10 @@ export class MDCMenuFoundation extends MDCFoundation {
getParentElement: () => null,
getSelectedElementIndex: () => -1,
notifySelected: () => undefined,
+ getMenuItemCount: () => 0,
+ focusItemAtIndex: () => undefined,
+ isRootFocused: () => false,
+ focusRoot: () => undefined,
};
// tslint:enable:object-literal-sort-keys
}
@@ -77,6 +86,19 @@ export class MDCMenuFoundation extends MDCFoundation {
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) {
@@ -97,6 +119,33 @@ export class MDCMenuFoundation extends MDCFoundation {
}, 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.
*/
diff --git a/packages/mdc-select/component.ts b/packages/mdc-select/component.ts
index a3682bf0d34..a229e86a79c 100644
--- a/packages/mdc-select/component.ts
+++ b/packages/mdc-select/component.ts
@@ -155,11 +155,14 @@ export class MDCSelect extends MDCComponent 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
diff --git a/test/screenshot/golden.json b/test/screenshot/golden.json
index 755a6e94f4b..a23578c04bb 100644
--- a/test/screenshot/golden.json
+++ b/test/screenshot/golden.json
@@ -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": {
diff --git a/test/screenshot/spec/mdc-menu/fixture.js b/test/screenshot/spec/mdc-menu/fixture.js
index 20413398e37..06738173e0c 100644
--- a/test/screenshot/spec/mdc-menu/fixture.js
+++ b/test/screenshot/spec/mdc-menu/fixture.js
@@ -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;
});
diff --git a/test/unit/mdc-menu/mdc-menu.test.js b/test/unit/mdc-menu/mdc-menu.test.js
index e061ff6be27..20ab2c95bb2 100644
--- a/test/unit/mdc-menu/mdc-menu.test.js
+++ b/test/unit/mdc-menu/mdc-menu.test.js
@@ -28,6 +28,7 @@ import domEvents from 'dom-events';
import {MDCMenu, MDCMenuFoundation} from '../../../packages/mdc-menu/index';
import {Corner} from '../../../packages/mdc-menu-surface/constants';
+import {cssClasses} from '../../../packages/mdc-menu/constants';
import {MDCListFoundation} from '../../../packages/mdc-list/index';
import {MDCMenuSurfaceFoundation} from '../../../packages/mdc-menu-surface/foundation';
@@ -110,6 +111,20 @@ function setupTest(open = false) {
return {root, component};
}
+/**
+ * @param {!Object=} options
+ * @return {{component: !MDCMenu, root: !HTMLElement, mockFoundation: !MDCMenuFoundation}}
+ */
+function setupTestWithMock(options = {open: true}) {
+ const root = getFixture(options.open);
+
+ const MockFoundationCtor = td.constructor(MDCMenuFoundation);
+ const mockFoundation = new MockFoundationCtor();
+
+ const component = new MDCMenu(root, mockFoundation);
+ return {root, component, mockFoundation};
+}
+
suite('MDCMenu');
test('destroy causes the menu-surface and list to be destroyed', () => {
@@ -265,20 +280,25 @@ test('setAbsolutePosition', () => {
td.verify(menuSurface.setAbsolutePosition(100, 120));
});
-test('menu surface opened event causes first element to be focused', () => {
+test('menu surface opened event causes root element to be focused', () => {
const {root} = setupTest();
document.body.appendChild(root);
const event = document.createEvent('Event');
event.initEvent(MDCMenuSurfaceFoundation.strings.OPENED_EVENT, false, true);
root.dispatchEvent(event);
- assert.equal(document.activeElement, root.querySelector('.mdc-list-item'));
+ assert.isTrue(document.activeElement.classList.contains(cssClasses.ROOT));
document.body.removeChild(root);
});
-test('menu surface opened event causes no element to be focused if the list is empty', () => {
+test('handleMenuSurfaceOpened calls foundation\'s handleMenuSurfaceOpened method on menu surface opened event', () => {
+ const {root, mockFoundation} = setupTestWithMock();
+ domEvents.emit(root, MDCMenuSurfaceFoundation.strings.OPENED_EVENT);
+ td.verify(mockFoundation.handleMenuSurfaceOpened());
+});
+
+test('menu surface opened event causes root element to be focused if the list is empty', () => {
const {root} = setupTest();
- const lastActiveElement = document.activeElement;
root.querySelector('.mdc-list').innerHTML = ''; // Quick clear of all list items
const event = document.createEvent('Event');
event.initEvent(MDCMenuSurfaceFoundation.strings.OPENED_EVENT, false, true);
@@ -286,7 +306,7 @@ test('menu surface opened event causes no element to be focused if the list is e
root.dispatchEvent(event);
- assert.equal(document.activeElement, lastActiveElement);
+ assert.isTrue(document.activeElement.classList.contains(cssClasses.ROOT));
document.body.removeChild(root);
});
@@ -315,6 +335,13 @@ test('open=true does not throw an error if there are no items in the list to foc
document.body.removeChild(root);
});
+test('#setDefaultFocusItemIndex Calls foundation\'s setDefaultFocusItemIndex method', () => {
+ const {component, mockFoundation} = setupTestWithFakes();
+
+ component.setDefaultFocusItemIndex(2);
+ td.verify(mockFoundation.setDefaultFocusItemIndex(2));
+});
+
// Adapter method test
test('adapter#addClassToElementAtIndex adds a class to the element at the index provided', () => {
@@ -413,3 +440,39 @@ test('adapter#notifySelected emits an event for a selected element', () => {
component.getDefaultFoundation().adapter_.notifySelected(0);
td.verify(handler(td.matchers.anything()));
});
+
+test('adapter#getMenuItemCount returns the menu item count', () => {
+ const {component} = setupTest();
+ assert.equal(component.getDefaultFoundation().adapter_.getMenuItemCount(), component.items.length);
+});
+
+test('adapter#focusItemAtIndex focuses the menu item at given index', () => {
+ const {root, component} = setupTest();
+ document.body.appendChild(root);
+
+ component.getDefaultFoundation().adapter_.focusItemAtIndex(2);
+ assert.equal(document.activeElement, component.items[2]);
+
+ document.body.removeChild(root);
+});
+
+test('adapter#isRootFocused returns true if menu root has focus', () => {
+ const {root, component} = setupTest();
+ document.body.appendChild(root);
+
+ assert.isFalse(component.getDefaultFoundation().adapter_.isRootFocused());
+ root.focus();
+ assert.isTrue(component.getDefaultFoundation().adapter_.isRootFocused());
+
+ document.body.removeChild(root);
+});
+
+test('adapter#focusRoot focuses the menu root element', () => {
+ const {root, component} = setupTest();
+ document.body.appendChild(root);
+
+ component.getDefaultFoundation().adapter_.focusRoot();
+ assert.equal(document.activeElement, root);
+
+ document.body.removeChild(root);
+});
diff --git a/test/unit/mdc-menu/menu.foundation.test.js b/test/unit/mdc-menu/menu.foundation.test.js
index 8dee5735a9a..cdda3124720 100644
--- a/test/unit/mdc-menu/menu.foundation.test.js
+++ b/test/unit/mdc-menu/menu.foundation.test.js
@@ -29,6 +29,7 @@ import {install as installClock} from '../helpers/clock';
import {MDCMenuFoundation} from '../../../packages/mdc-menu/foundation';
import {MDCListFoundation} from '../../../packages/mdc-list/foundation';
import {cssClasses, strings} from '../../../packages/mdc-menu/constants';
+import {numbers as menuNumbers} from '../../../packages/mdc-menu/constants';
import {numbers} from '../../../packages/mdc-menu-surface/constants';
function setupTest() {
@@ -45,7 +46,7 @@ test('defaultAdapter returns a complete adapter implementation', () => {
verifyDefaultAdapter(MDCMenuFoundation, [
'addClassToElementAtIndex', 'removeClassFromElementAtIndex', 'addAttributeToElementAtIndex',
'removeAttributeFromElementAtIndex', 'elementContainsClass', 'closeSurface', 'getElementIndex', 'getParentElement',
- 'getSelectedElementIndex', 'notifySelected',
+ 'getSelectedElementIndex', 'notifySelected', 'getMenuItemCount', 'focusItemAtIndex', 'isRootFocused', 'focusRoot',
]);
});
@@ -57,6 +58,10 @@ test('exports cssClasses', () => {
assert.deepEqual(MDCMenuFoundation.cssClasses, cssClasses);
});
+test('exports numbers', () => {
+ assert.deepEqual(MDCMenuFoundation.numbers, menuNumbers);
+});
+
test('destroy does not throw error', () => {
const {foundation} = setupTest();
assert.doesNotThrow(() => foundation.destroy());
@@ -97,7 +102,39 @@ test('handleKeydown tab key causes the menu to close', () => {
td.verify(mockAdapter.elementContainsClass(td.matchers.anything()), {times: 0});
});
-test('handleItemAction item action causes the menu to close', () => {
+test('handleKeydown arrowDown key focuses first menu item', () => {
+ const {foundation, mockAdapter} = setupTest();
+ const event = {key: 'ArrowDown', preventDefault: () => {}};
+
+ td.when(mockAdapter.isRootFocused()).thenReturn(true);
+ foundation.handleKeydown(event);
+ td.verify(mockAdapter.focusItemAtIndex(0));
+});
+
+test('handleKeydown arrowUp key focuses last menu item', () => {
+ const {foundation, mockAdapter} = setupTest();
+ const event = {key: 'ArrowUp', preventDefault: () => {}};
+
+ td.when(mockAdapter.isRootFocused()).thenReturn(true);
+ td.when(mockAdapter.getMenuItemCount()).thenReturn(5);
+
+ foundation.handleKeydown(event);
+ td.verify(mockAdapter.focusItemAtIndex(4));
+});
+
+test('handleKeydown arrowing through the menu when one of the list item is already focused menu does not shift the ' +
+ 'focus', () => {
+ const {foundation, mockAdapter} = setupTest();
+
+ td.when(mockAdapter.isRootFocused()).thenReturn(false);
+ foundation.handleKeydown({key: 'ArrowUp'});
+ foundation.handleKeydown({key: 'ArrowDown'});
+
+ // List component handles navigation through list items.
+ td.verify(mockAdapter.focusItemAtIndex(td.matchers.anything()), {times: 0});
+});
+
+test('handleItemAction item action closes the menu', () => {
const {foundation, mockAdapter} = setupTest();
const itemEl = document.createElement('li');
@@ -107,7 +144,7 @@ test('handleItemAction item action causes the menu to close', () => {
td.verify(mockAdapter.closeSurface(), {times: 1});
});
-test('handleItemAction item action causes the menu to emit the selected event', () => {
+test('handleItemAction item action emits selected event', () => {
const {foundation, mockAdapter} = setupTest();
const itemEl = document.createElement('li');
@@ -219,6 +256,40 @@ test('handleItemAction item action event inside of a child element of a selectio
{times: 0});
});
+test('handleMenuSurfaceOpened menu focuses the root element by default on menu surface opened', () => {
+ const {foundation, mockAdapter} = setupTest();
+
+ td.when(mockAdapter.getMenuItemCount()).thenReturn(5);
+ foundation.handleMenuSurfaceOpened();
+ td.verify(mockAdapter.focusRoot());
+});
+
+test('handleMenuSurfaceOpened menu focuses the first menu item when default focus item index is set to 0 on menu ' +
+ 'surface opened', () => {
+ const {foundation, mockAdapter} = setupTest();
+
+ td.when(mockAdapter.getMenuItemCount()).thenReturn(5);
+ foundation.setDefaultFocusItemIndex(0);
+ foundation.handleMenuSurfaceOpened();
+ td.verify(mockAdapter.focusItemAtIndex(0));
+});
+
+test('setDefaultFocusItemIndex focuses the root element on menu open when set to numbers.FOCUS_ROOT_INDEX', () => {
+ const {foundation, mockAdapter} = setupTest();
+
+ td.when(mockAdapter.getMenuItemCount()).thenReturn(5);
+ foundation.setDefaultFocusItemIndex(menuNumbers.FOCUS_ROOT_INDEX);
+ foundation.handleMenuSurfaceOpened();
+ td.verify(mockAdapter.focusRoot());
+});
+
+test('setDefaultFocusItemIndex throws error when focus item index is out of range', () => {
+ const {foundation, mockAdapter} = setupTest();
+
+ td.when(mockAdapter.getMenuItemCount()).thenReturn(5);
+ assert.throws(() => foundation.setDefaultFocusItemIndex(99));
+});
+
// Item Action
test('Item action event causes the menu to close', () => {