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 and buttonId constants refer to the ID attributes of the menu and menu button.
  • The wrapper constant refers to the main element, which wraps the menu and menu button.
  • The button constant refers to the menu button, after invoking the createButton() 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 the buttonId constant. The aria-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 the menuId 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');
}
}

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;
}
}
  1. 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.
  2. We check if the item in focus is the first menu item or the last menu item, assigning these boolean values to the isFirstItem and isLastItem constants, respectively.
  3. 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.
  1. If we're on the last item and the Tab key was pressed, close the menu.
  2. If the key corresponds to one of the properties in our keyHandlers.item object, call that function.
  3. 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();
}
}
  1. If the character length is greater than 1, we use an empty return statement to stop the function execution.
  2. We convert the character value, char, to lowercase. This is because all the characters in our firstChars array are lowercase.
  3. 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.
  4. From our starting index, we check if the character value appears in our firstChars array.
  5. If we couldn't find the character value from our starting index, we check again from the beginning of the firstChars array.
  6. 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();
}
  1. If the element that fired the event (accessible via the event.target property) is inside our wrapper element, we exit the function and do nothing.
  2. If the menu is closed, we exit the function and do nothing.
  3. 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 the mousedown 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.