diff --git a/elements/howto-menu-button/README.md b/elements/howto-menu-button/README.md new file mode 100644 index 00000000..6d20020a --- /dev/null +++ b/elements/howto-menu-button/README.md @@ -0,0 +1,4 @@ +A menu button is a button that opens a menu. +It is referenced by the menu using aria-labelledby attribute. + +See: https://www.w3.org/TR/wai-aria-practices-1.1/#menubutton diff --git a/elements/howto-menu-button/demo.html b/elements/howto-menu-button/demo.html new file mode 100644 index 00000000..096637de --- /dev/null +++ b/elements/howto-menu-button/demo.html @@ -0,0 +1,32 @@ + + + + +
+ Menu + + +
diff --git a/elements/howto-menu-button/howto-menu-button.e2etest.js b/elements/howto-menu-button/howto-menu-button.e2etest.js new file mode 100644 index 00000000..c6e84d67 --- /dev/null +++ b/elements/howto-menu-button/howto-menu-button.e2etest.js @@ -0,0 +1,62 @@ +const helper = require('../../tools/selenium-helper.js'); +const expect = require('chai').expect; +const {Key} = require('selenium-webdriver'); + +describe('howto-menu-button', function() { + let success; + let driver; + beforeEach(function() { + driver = this.driver; + return this.driver.get(`${this.address}/howto-menu-button/demo.html`) + .then(_ => helper.waitForElement(this.driver, 'howto-menu-button')); + }); + + function _focusMenuBtn() { + return driver.executeScript(_ => { + window.menuBtn = document.querySelector('howto-menu-button'); + window.menuBtn.focus(); + }); + } + + async function _assessFirstItemFocused() { + success = await driver.executeScript(` + return document.activeElement === + document.querySelector('[role="menuitem"]'); + `); + expect(success).to.equal(true); + } + + async function _assessMenuOpened() { + success = await driver.executeScript(` + const menu = document.querySelector('[aria-labelledby="menu-btn"]'); + return menu.getAttribute('aria-hidden') === 'false'; + `); + expect(success).to.equal(true); + } + + let tests = [ + {key: 'ARROW_DOWN'}, + {key: 'ENTER'}, + {key: 'SPACE'}, + ]; + + tests.forEach(function(test) { + it('should open the menu on [' + test.key + ']', + async function() { + await _focusMenuBtn(); + await this.driver.actions().sendKeys(Key[test.key]).perform(); + await _assessMenuOpened(); + } + ); + }); + + tests.forEach(function(test) { + it('should focus the first menu item on [' + test.key + ']', + async function() { + await _focusMenuBtn(); + await this.driver.actions().sendKeys(Key[test.key]).perform(); + await _assessFirstItemFocused(); + } + ); + }); +}); diff --git a/elements/howto-menu-button/howto-menu-button.js b/elements/howto-menu-button/howto-menu-button.js new file mode 100644 index 00000000..81dbc02c --- /dev/null +++ b/elements/howto-menu-button/howto-menu-button.js @@ -0,0 +1,82 @@ +(function() { + /** + * Define key codes to help with handling keyboard events. + */ + const KEYCODE = { + DOWN: 40, + ENTER: 13, + SPACE: 32, + }; + + class HowtoMenuButton extends HTMLElement { + /** + * A getter for the first menuitem in the menu. + */ + get firstMenuItem() { + return this._menu.querySelector('[role^="menuitem"]:first-of-type'); + } + + /** + * Returns true if the menu is currently opened. + */ + _isMenuOpen() { + return !(this._menu.getAttribute('aria-hidden') === 'true'); + } + + /** + * Opens the menu if it was closed and vice versa. + */ + _toggleMenu() { + const isOpen = this._isMenuOpen(); + this._menu.setAttribute('aria-hidden', isOpen); + if (!isOpen) { + // Set focus on first menuitem. + this.firstMenuItem && this.firstMenuItem.focus(); + } + } + + /** + * Controls keyboard interactions. + */ + _handleKeydown(e) { + const triggers = [KEYCODE.DOWN, KEYCODE.ENTER, KEYCODE.SPACE]; + if (triggers.indexOf(e.keyCode) > -1) { + this._toggleMenu(); + } + } + + /** + * Sets up the menu button element. + */ + connectedCallback() { + this.setAttribute('role', 'button'); + this.setAttribute('aria-label', 'menu'); + this.setAttribute('aria-haspopup', true); + this.setAttribute('tabindex', 0); + + this.style.display = 'inline-block'; + this.style.width = parseInt(this.getAttribute('width'), 10) + 'px'; + this.style.height = parseInt(this.getAttribute('height'), 10) + 'px'; + this.style.overflow = 'hidden'; + this.style.color = 'transparent'; + this.style.background = ('linear-gradient(to bottom, black, black 20%,' + + ' white 20%, white 40%, black 40%, black 60%, white 60%, white 80%,' + + ' black 80%, black 100%)'); + + this._menu = document.querySelector('[aria-labelledby=' + this.id + ']'); + // TODO: Should this relationship use labelledby or aria-controls? + this.addEventListener('click', this._toggleMenu); + this.addEventListener('keydown', this._handleKeydown); + } + + /** + * Unregisters the event listeners that were set up in connectedCallback. + */ + disconnectedCallback() { + this.removeEventListener('click', this._toggleMenu); + this.removeEventListener('keydown', this._handleKeydown); + } + } + + window.customElements.define('howto-menu-button', HowtoMenuButton); +})();