Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions elements/howto-menu-button/README.md
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions elements/howto-menu-button/demo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<!doctype html>

<style>
ul {
position: absolute;
display: block;
width: 10em;
padding: 0;
}

li {
display: block;
width: 100%;
padding: .2em .5em;
border: 1px solid lightgrey;
}

/* The menu is closed. */
ul[aria-hidden="true"] {
display: none;
}
</style>

<div class="text">
<howto-menu-button aria-label="menu" id="menu-btn" width='20' height='20'>Menu</howto-menu-button>

<ul aria-role="menu" aria-hidden="true" aria-labelledby="menu-btn">
<li role="menuitem" tabindex="0">option 1</li>
<li role="menuitem" tabindex="0">option 2</li>
<li role="menuitem" tabindex="0">option 3</li>
</ul>
</div>
62 changes: 62 additions & 0 deletions elements/howto-menu-button/howto-menu-button.e2etest.js
Original file line number Diff line number Diff line change
@@ -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();
}
);
});
});
82 changes: 82 additions & 0 deletions elements/howto-menu-button/howto-menu-button.js
Original file line number Diff line number Diff line change
@@ -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);
})();