In the last instalment of this series, we looked at an accessible disclosure (show/hide) component based on Dave Rupert's Nutrition Cards for Accessible Components. This week, we're going to look at an accessible menu and menu button component.
Demo
Before we get started, you can view the demo and check the source code on GitHub. The expected behaviours are detailed in the nutrition card and the WAI-ARIA Authoring Practices. Please test them—who knows, I may have missed something!
Progressive enhancement
We're going to build our menu with progressive enhancement in mind. Without JavaScript, we'll just have a list of links:
<main>
<ul id="menu">
<li>
<a href="#">Home</a>
</li>
<li>
<a href="#">About</a>
</li>
<li>
<a href="#">Blog</a>
</li>
<li>
<a href="#">Gallery</a>
</li>
<li>
<a href="#">Contact</a>
</li>
</ul>
</main>
Because this is just a demo, I've set the links' href
attribute to #
, which links them to the top of the page. If this were a real application, I'd use real links to other pages.
Creating the menu and menu button
Before we can add any interactivity, we need to enhance our list of links into a menu component. Let's start with some constants:
const menuId = 'menu';
const buttonId = 'menu-button';
const wrapper = document.querySelector('main');
const button = createButton();
const menu = wrapper.querySelector(`#${menuId}`);
const menuItems = [ ...menu.querySelectorAll('a') ];
- The
menuId
andbuttonId
constants refer to the ID attributes of the menu and menu button. - The
wrapper
constant refers to themain
element, which wraps the menu and menu button. - The
button
constant refers to the menu button, after invoking thecreateButton()
function. - The
menu
constant refers to the unordered list of links, which we'll enhance into the menu component. - The
menuItems
constant refers to an array of links inside the menu.
Now let's look at the createButton()
function:
/**
* Create the menu button.
* @returns {HTMLButtonElement}
*/
function createButton() {
const button = document.createElement('button');
button.id = buttonId;
button.type = 'button';
button.textContent = 'Navigation';
button.setAttribute('aria-controls', menuId);
button.setAttribute('aria-haspopup', 'true');
return button;
}
- We set the
id
property to the value of thebuttonId
constant. Thearia-labelledby
attribute on the menu will link to this ID. - We set the
type
property to the string value'button'
. This means the button doesn't do anything by default; its behaviour is controlled via JavaScript. - We set the
textContent
property to the string value'Navigation'
. This is the button's accessible text label. - We set the
aria-controls
attribute to the value of themenuId
constant. This links to the ID attribute on the menu, and signifies that the button controls the menu. - We set the
aria-haspopup
attribute to the string value'true'
. This signifies that the button triggers an interactive popup element, i.e. the menu.
We also have a function, createMenu()
, which enhances the list of links into a true menu component:
/**
* Create the menu.
*/
function createMenu() {
const listItems = [ ...menu.children ];
menu.hidden = true;
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-labelledby', buttonId);
for (const listItem of listItems) {
listItem.setAttribute('role', 'presentation');
listItem.firstElementChild.setAttribute('role', 'menuitem');
}
}
- The
listItems
constant refers to an array of the menu's direct children, which are list items. - We set the menu's
hidden
property to the boolean valuetrue
, which sets thehidden
attribute and hides the menu. - We set the menu's
role
attribute to the string value'menu'
, which identifies the element as a menu that offers a list of choices to the user. - We set the menu's
aria-labelledby
attribute to the value of thebuttonId
constant. This links to the ID attribute on the button element and identifies the button as the label for the menu. - For each list item, we:
- Set its
role
attribute to the string value'presentation'
. This hides the list item's semantics from assistive technologies. This is necessary because the semantics of descendants ofmenuitem
elements are not exposed to the accessibility tree. This means we can't use the list item as a menu item; we need to use its child anchor element. See Roles That Automatically Hide Semantics by Making Their Descendants Presentational. - Set the
role
attribute of the list item's first child (a link) to the string valuemenuitem
. This indicates that the link is one of the items in the menu.
- Set its
Finally, we have an init()
function which initialises the script:
/**
* Initialise the script.
*/
function init() {
createMenu();
menu.before(button);
}
The init()
function invokes the createMenu()
function and uses the Element.before()
method to insert the button before the menu. We'll invoke the init()
function from the bottom of our script:
init();
Handling button clicks
With our semantic and accessible HTML in place, let's handle click
events on our menu button element. We'll create a function, handleButtonClick()
, to handle this:
/**
* Handle click events on the button.
*/
function handleButtonClick() {
if (isMenuOpen()) {
closeMenuAndFocusButton();
} else {
openMenuAndFocusFirstItem();
}
}
If the menu is open, we close it and shift focus back to the menu button. If the menu is closed, we open it and shift focus to the first menu item.
We'll use the handleButtonClick()
function as the callback function for the addEventListener()
method on the button:
button.addEventListener('click', handleButtonClick);
The handleButtonClick()
function references three other functions:
isMenuOpen()
closeMenuAndFocusButton()
openMenuAndFocusFirstItem()
Checking if the menu is open
The isMenuOpen()
function checks if the menu is open:
/**
* Check if the menu is open.
* @returns {boolean}
*/
function isMenuOpen() {
return button.getAttribute('aria-expanded') === 'true';
}
We check if the button's aria-expanded
attribute is equal to the string value 'true'
. If it is, that means the menu is open; otherwise, it's closed. We return this boolean value from the function.
Closing the menu and shifting focus to the button
The closeMenuAndFocusButton()
function closes the menu and shifts focus to the menu button:
/**
* Close the menu and shift focus to the menu button.
*/
function closeMenuAndFocusButton() {
closeMenu();
button.focus();
}
To close the menu, it invokes the closeMenu()
function:
/**
* Close the menu.
*/
function closeMenu() {
menu.hidden = true;
button.removeAttribute('aria-expanded');
}
This sets the menu's hidden
property to the boolean value true
, thereby hiding it. It also removes the aria-expanded
attribute from the button, which signifies that the menu is closed.
To shift focus to the button, the closeMenuAndFocusButton()
function calls the button's focus()
method.
Opening the menu and shifting focus to the first item
The openMenuAndFocusFirstItem()
function opens the menu and shifts focus to the first item:
/**
* Open the menu and shift focus to the first item.
*/
function openMenuAndFocusFirstItem() {
openMenu();
focusFirstItem();
}
To open the menu, it invokes the openMenu()
function:
/**
* Open the menu.
*/
function openMenu() {
menu.hidden = false;
button.setAttribute('aria-expanded', 'true');
}
This sets the menu's hidden
property to the boolean value false
, thereby showing it. It also sets the button's aria-expanded
attribute to the string value 'true'
, which signifies that the menu is open.
To shift focus to the first item, the openMenuAndFocusFirstItem()
function invokes the focusFirstItem()
function:
/**
* Shift focus to the first menu item.
*/
function focusFirstItem() {
menuItems[0].focus();
}
This, in turn, invokes the focus()
method on the first link in the menuItems
array.
Handling menu item mouseovers
Like a native system menu, we need to shift focus to a menu item when the user moves their mouse over it. Let's create a function, handleItemMouseover()
, to handle this:
/**
* Handle mouseover events on the menu items.
* @param {MouseEvent} event
*/
function handleItemMouseover(event) {
event.target.focus();
}
We invoke the focus()
method on the element that fired the event, which is accessible via the event.target
property. The focus()
method only shifts focus to elements that actually can be focused, so we don't need to worry about elements that can't, such as the list items. Those will fail silently.
Because the mouseover
event supports bubbling, we can just attach the event listener to the parent menu element. This means we don't have to attach it to every menu item separately, which would be unnecessary work for the browser:
menu.addEventListener('mouseover', handleItemMouseover);
Handling keydown events
Now let's handle keydown
events.
Creating an object of key handlers
First, near the top of our script, we'll add a keyHandlers
object literal:
const keyHandlers = {};
keyHandlers.button = {
' ': openMenuAndFocusFirstItem,
Enter: openMenuAndFocusFirstItem,
ArrowDown: openMenuAndFocusFirstItem,
ArrowUp: openMenuAndFocusLastItem,
Escape: closeMenuAndFocusButton
};
keyHandlers.item = {
' ': activateItem,
ArrowDown: focusNextItem,
ArrowUp: focusPrevItem,
End: focusLastItem,
PageDown: focusLastItem,
Home: focusFirstItem,
PageUp: focusFirstItem,
Escape: closeMenuAndFocusButton
};
The keyHandlers
object has two properties, button
and item
, each of which is also an object literal. They refer to the key handlers for the button and menu items, respectively. Each of these objects' properties (' '
, ArrowDown
, etc.) refers to a possible value for the KeyboardEvent.key
property. The values refer to the functions that should be called when those key are pressed.
These objects reference a few more functions we haven't seen yet:
openMenuAndFocusLastItem()
activateItem()
focusNextItem()
focusPrevItem()
focusLastItem()
Opening the menu and shifting focus to the last item
The openMenuAndFocusLastItem()
is similar to the openMenuAndFocusFirstItem()
function we saw earlier, except it shifts focus to the last item instead of the first:
/**
* Open the menu and shift focus to the last item.
*/
function openMenuAndFocusLastItem() {
openMenu();
focusLastItem();
}
It invokes the focusLastItem()
method, which again, is like the focusFirstItem()
function we saw earlier:
/**
* Shift focus to the last menu item.
*/
function focusLastItem() {
menuItems[menuItems.length - 1].focus();
}
An array's maximum index is always one less than its length property, which is how we get the last item. Then we call its focus()
method.
Activating a menu item
The activateItem()
function activates an item in the menu:
/**
* Activate a menu item.
* @param {KeyboardEvent} event
*/
function activateItem(event) {
event.target.click();
}
To activate the item, we invoke its click()
method. The event.target
property refers to the element that was in focus when the the keyboard event was fired.
Shifting focus to the next menu item
The focusNextItem()
function shifts focus to the next menu item:
/**
* Shift focus to the next menu item.
*/
function focusNextItem() {
const indexInFocus = findIndexInFocus();
const nextItem = menuItems[indexInFocus + 1];
nextItem ? nextItem.focus() : focusFirstItem();
}
It gets the index of the item in focus by invoking another function, findIndexInFocus()
:
/**
* Find the index of the menu item in focus.
* @returns {number}
*/
function findIndexInFocus() {
return menuItems.findIndex(item => item === document.activeElement);
}
Then, the focusNextItem()
function gets the index of the next item based on the index of the current item.
If there is an item to follow, it calls its focus()
method. If not, that means we're at the end of the menu, so it shifts focus to the first item instead.
Shifting focus to the previous menu item
The focusPrevItem()
function shifts focus to the previous menu item:
/**
* Shift focus to the previous menu item.
*/
function focusPrevItem() {
const indexInFocus = findIndexInFocus();
const prevItem = menuItems[indexInFocus - 1];
prevItem ? prevItem.focus() : focusLastItem();
}
If there's a preceding item, it shifts focus to that item. If not, that means we're at the beginning of the menu, so it invokes the focusLastItem()
function instead.
Handling all keydown events
We'll create a handleKeydown()
function to handle all keydown events inside our component:
/**
* Handle keydown events inside the component.
* @param {KeyboardEvent} event
*/
function handleKeydown(event) {
if (button === event.target) {
handleButtonKeydown(event);
} else if (menuItems.includes(event.target)) {
handleItemKeydown(event);
}
}
If the menu button is in focus, we'll invoke the handleButtonKeydown()
function. If a menu item is in focus, we'll invoke the handleItemKeydown()
function.
We'll use our handleKeydown()
function as the callback function for the addEventListener()
method on our wrapper
element:
wrapper.addEventListener('keydown', handleKeydown);
Handling keydown events on the button
Here's our handleButtonKeydown()
function:
/**
* Handle keydown events on the button.
* @param {KeyboardEvent} event
*/
function handleButtonKeydown(event) {
if (!keyHandlers.button.hasOwnProperty(event.key)) return;
event.preventDefault();
keyHandlers.button[event.key]();
}
If the keyHandlers.button
object doesn't have a property corresponding to the key that was pressed, we use an empty return
statement to exit the function.
Otherwise, we prevent the key's default action and call the corresponding function.
Handling keydown events on the menu items
The handleItemKeydown()
function is the most complex function in our codebase, but we'll step through it piece by piece. Here it is:
/**
* Handle keydown events on the menu items.
* @param {KeyboardEvent} event
*/
function handleItemKeydown(event) {
if (event.ctrlKey || event.altKey || event.metaKey) return;
const isFirstItem = event.target === menuItems[0];
const isLastItem = event.target === menuItems[menuItems.length - 1];
if (event.shiftKey) {
if (isPrintableCharacter(event.key)) {
event.preventDefault();
focusItemByChar(event.target, event.key);
} else if (isFirstItem && event.key === 'Tab') {
event.preventDefault();
closeMenuAndFocusButton();
}
return;
}
if (isLastItem && event.key === 'Tab') {
closeMenu();
return;
}
if (keyHandlers.item.hasOwnProperty(event.key)) {
event.preventDefault();
keyHandlers.item[event.key](event);
return;
}
if (isPrintableCharacter(event.key)) {
event.preventDefault();
focusItemByChar(event.target, event.key);
return;
}
}
- If the ctrl key, the alt key, or a meta key was pressed, we use an empty
return
statement to exit the function and do nothing else. - We check if the item in focus is the first menu item or the last menu item, assigning these boolean values to the
isFirstItem
andisLastItem
constants, respectively. - If the Shift key was pressed...
- ...and the key produces a character value, shift focus to the item that begins with that character (if any).
- ...and we're on the first item, and the Tab key was pressed, close the menu and shift focus back to the menu button.
- If we're on the last item and the Tab key was pressed, close the menu.
- If the key corresponds to one of the properties in our
keyHandlers.item
object, call that function. - If the key produces a character value, shift focus to the item that begins with that character (if any).
I borrowed the isPrintableCharacter()
and focusItemByChar()
functions from the Navigation Menu Button Example in the WAI-ARIA Authoring Practices 1.2, with a few modifications. This is allowed under the terms of the W3C Software License, as long as you provide a link to the license and make note of any modifications. I have done this at the top of my JavaScript file.
Checking if a character is printable
The isPrintableCharacter()
function checks if a character is printable, i.e. it produces a printable character value:
/**
* Check if a character is printable.
* https://w3c.github.io/aria-practices/examples/menu-button/js/menu-button-links.js
* @param {string} str
* @returns {boolean}
*/
function isPrintableCharacter(str) {
return str.length === 1 && str.match(/\S/);
}
It checks that its argument is only one character long, and that it is not a whitespace character (that's what the \S
character class in the regular expression literal means).
Shifting focus to an item based on its first character
The focusItemByChar()
function shifts focus to a menu item based on its first character. Before we look at the function, we need to create another constant near the top of our script:
const firstChars = menuItems.map(item => {
return item.textContent.toLowerCase().trim().charAt(0);
});
We take the array of menu items and add each item's first character, in lowercase, to a new array. The firstChars
constant refers to this new array.
With that in mind, let's look at the focusItemByChar()
function:
/**
* Shift focus to a menu item based on its first character.
* https://w3c.github.io/aria-practices/examples/menu-button/js/menu-button-links.js
* @param {HTMLAnchorElement} currentItem
* @param {string} char
*/
function focusItemByChar(currentItem, char) {
if (char.length > 1) return;
let start, index;
char = char.toLowerCase();
// Get start index for search based on position of currentItem
start = menuItems.indexOf(currentItem) + 1;
if (start >= menuItems.length) {
start = 0;
}
// Check remaining slots in the menu
index = firstChars.indexOf(char, start);
// If not found in remaining slots, check from beginning
if (index === -1) {
index = firstChars.indexOf(char, 0);
}
// If match was found...
if (index > -1) {
menuItems[index].focus();
}
}
- If the character length is greater than 1, we use an empty
return
statement to stop the function execution. - We convert the character value,
char
, to lowercase. This is because all the characters in ourfirstChars
array are lowercase. - We get the starting index for our search based on the position of the current menu item. If this index takes us to the end of our
firstChars
array, we start from the beginning instead. - From our starting index, we check if the character value appears in our
firstChars
array. - If we couldn't find the character value from our starting index, we check again from the beginning of the
firstChars
array. - Finally, if a match was found, we shift focus to the menu item at that index.
Handling mousedown events outside the component
Finally, we need to handle mousedown
events that occur outside our menu and menu button. Let's create a handleMousedown()
function:
/**
* Handle mousedown events outside the component.
* @param {MouseEvent} event
*/
function handleMousedown(event) {
if (wrapper.contains(event.target)) return;
if (!isMenuOpen()) return;
event.preventDefault();
closeMenuAndFocusButton();
}
- If the element that fired the event (accessible via the
event.target
property) is inside ourwrapper
element, we exit the function and do nothing. - If the menu is closed, we exit the function and do nothing.
- Otherwise, we close the menu and shift focus to the menu button. The call to the
event.preventDefault()
method is necessary, because otherwise, the default action of themousedown
event is to shift focus to the element that fired the event—not the menu button. This prevents that.
We need to invoke the handleMousedown()
function for all mousedown
events that occur outside our component, so we'll attach it to the html
element. We can access this element via the document.documentElement
property:
document.documentElement.addEventListener('mousedown', handleMousedown);
Next week
I hope you've found this walkthrough informative! Stay tuned for next week, when we'll look at an accessible tabs component.