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', () => {