How to build an accessible navigation menu
The header navigation here on this site is built using a somewhat complex custom UI component that mimics the functionality of a native select
component. Making this navigation component accessible and functional for screen readers and keyboard users posed a challenge.
Reference ARIA Authoring Best Practices docs
My first stop was to reference the W3C docs on authoring rich and accessible internet apps. Reviewing the list of design patters and widgets, I narrowed down my navigation component to either a Listbox or Menu:
- Listbox
- A listbox widget presents a list of options and allows a user to select one or more of them. A listbox that allows a single option to be chosen is a single-select listbox; one that allows multiple options to be selected is a multi-select listbox.
- Menu
- A menu is a widget that offers a list of choices to the user, such as a set of actions or functions. A menu is usually opened, or made visible, by activating a menu button, choosing an item in a menu that opens a sub menu, or by invoking a command... that opens a context specific menu. When a user activates a choice in a menu, the menu usually closes unless the choice opened a submenu.
Although I prototyped the navigation component to mimic a select
component, and a Listbox sounds like exactly what I was looking for, I ultimately decided that my component was more Menu-like since I had a button that activated the menu dropdown and there would be only single options available.
In short, a listbox is for a selection of form-like items with the option of choosing one or more, while a menu is for navigation-like items with the option of only choosing one among a single menu or submenu(s).
Menu roles, states and properties
The relevant ARIA specifications this particular menu are:
- A menu is a container of items that represent choices. The element serving as the menu has a role of either
menu
ormenubar
. - The items contained in a menu are child elements of the containing menu or menubar and have any of the following roles:
menuitem
,menuitemcheckbox
,menuitemradio
- The following approach is used to enable scripts to move focus among items in a menu:
- Each item in the menu has tabindex set to -1.
- An element with role
menu
either has:aria-labelledby
set to a value that refers to the menuitem or button that controls its display.- A label provided by
aria-label
.
- If a menu is horizontally oriented, it has
aria-orientation
set tohorizontal
. The default value ofaria-orientation
for a menu isvertical
.
Menubutton roles, states, and properties
Another component in my navigation widget is the Menubutton controller that opens and closes the menu. The following ARIA implementations are relevant to this particular component:
- The element that opens the menu has role
button
. (My Menubutton is a<button>
so happily it is automatically given this role without explicitly coercing it into the role) - The element with role
button
hasaria-haspopup
set to eithermenu
ortrue
. - When the menu is displayed, the element with role
button
hasaria-expanded
set to true. When the menu is hidden, it is recommended thataria-expanded
is not present. ... - The element that contains the menu items displayed by activating the button has role
menu
.
Putting it all together
To put it all together, we need React to manage the state and effects of the menu and keydown
listeners to implement the keyboard interaction strategy defined in the specs.
function keyboardNav(event) {
const activeEl = document.activeElement
let activeIndex = Number(activeEl.dataset.menuIndex)
if (!activeIndex) {
activeIndex = 0
}
let newActiveIndex
const numListboxOptions =
document.getElementById('header__nav__menu').childElementCount + 1 // Account for button
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
newActiveIndex = activeIndex + 1
if (newActiveIndex >= numListboxOptions) {
newActiveIndex = 0
}
break
case 'ArrowUp':
event.preventDefault()
newActiveIndex = activeIndex - 1
if (newActiveIndex < 0) {
newActiveIndex = numListboxOptions - 1
}
break
case 'Home':
event.preventDefault()
newActiveIndex = 0
break
case 'End':
event.preventDefault()
newActiveIndex = numListboxOptions - 1
break
case 'Tab':
case 'Escape':
event.preventDefault()
document.getElementById('header__nav__trigger').click()
break
}
if (!!isNaN(newActiveIndex)) {
return
}
let focusEl = document.querySelectorAll(
`[data-menu-index="${newActiveIndex}"]`
)[0]
focusEl.tabIndex = 0
focusEl.focus()
}
function Header() {
const [nav, setNav] = useState({ opened: false })
const toggleNav = () => setNav({ opened: !nav.opened })
useEffect(() => {
if (nav.opened) {
document.addEventListener('keydown', keyboardNav)
} else {
document.removeEventListener('keydown', keyboardNav)
}
return () => document.removeEventListener('keydown', keyboardNav)
}, [nav])
return (
<nav>
<Button
id="header__nav__trigger"
onClick={toggleNav}
data-menu-index={0}
aria-haspopup="menu"
aria-controls="header__nav__menu"
aria-expanded={nav.opened}
>
{vaguelyGetPageTitle()}
</Button>
<ul id="header__nav__menu" role="menu" aria-label="site navigation">
{PAGES.map(({ link, title: pageTitle }, index) => (
<li key={link} role="menuitem" hidden={!nav.opened}>
<NavLink
href={link}
scroll={false}
navIndex={index}
className="nav-item unstyled"
tabIndex={-1}
data-menu-index={index + 1}
>
{pageTitle}
</NavLink>
</li>
))}
</ul>
</nav>
)
}
Hello, everyone! Our menu is fully accessible to you!
via GIPHY