Nested navigation
Normally a navigation is a list of links - figuratively and literally. So having a bunch of links within a UL
is not that big a deal. But what about a nested navigation? That's a little bit more tricky. Usually you'll have a second UL
within a LI
of the mother UL
. If you then throw in a display:none;
for the second UL
and the user hovers over the parent link, the second UL
gets a display:block;
- et voilà! The submenu appears "out of thin air". Unfortunately that doesn't count as a magic trick...
The problem here is that as soon as we have a display:none;
, the affected element - the submenu - is invisible to screen readers. So we have to add a little bit of JavaScript to our menu. We want to navigate with the tab key through our whole navigation and get to the "hidden gem", i.e. the submenu.
Spice up your markup
Before adding the JavaScript we should enrich our markup to make it more suitable for assistive technology. The basic nested navigation could look something like this:
<ul>
<li>
<a href="/products">Products</a>
<ul>
<li><a href="/hats">Hats</a></li>
<li><a href="/scarfs">Scarfs</a></li>
<li><a href="/gloves">Gloves</a></li>
</ul>
</li>
<li>
<a href="/employees">Employees</a>
<ul>
<li><a href="/sam">Sam</a></li>
<li><a href="/sarah">Sarah</a></li>
<li><a href="/john">John</a></li>
<li><a href="/marie">Marie</a></li>
</ul>
</li>
<li><a href="/contact">Contact</a></li>
</ul>
The first thing we'll do is tell our technology that each link with a submenu actually HAS a submenu. For that we'll use aria-haspopup="true"
:
<ul>
<li>
<a href="/products" aria-haspopup="true">Products</a>
<ul>
<li><a href="/hats">Hats</a></li>
...
ChromeVox, for example, will tell the user this: list item, products, link has popup
This way the user knows Ah, there’s more to this link...
In the next step we'll add aria-hidden="true"
to the hidden element just to be sure, and also tell the tech that the second level menu, our submenu, is not expanded so we end up with this:
<ul>
<li>
<a href="/products" aria-haspopup="true">Products</a>
<ul aria-hidden="true" aria-expanded="false">
<li><a href="/hats">Hats</a></li>
...
With a little help of our JavaScript (see below) we'll change the state of aria-expanded
as soon as focus on our mother element, in this case the products link. And what will happen is this (again in ChromeVox): As soon as the user gets to the first element of the submenu, ChromeVox will read the following out loud: list expanded with three items
Oh, I love this technology. It can do so much! But the developer has to do his bit by using the proper markup.
To make it more understandable for screen readers - and for good manners, so to speak - we'll finally add an aria-label
to the submenu, telling the user that it's actually that - a submenu:
<ul>
<li>
<a href="/products" aria-haspopup="true">Products</a>
<ul aria-hidden="true" aria-expanded="false" aria-label="products submenu">
<li><a href="/hats">Hats</a></li>
...
And we're good. This is what the final example will look like:
<ul>
<li>
<a href="/products" aria-haspopup="true">Products</a>
<ul aria-hidden="true" aria-expanded="false" aria-label="products submenu">
<li><a href="/hats">Hats</a></li>
<li><a href="/scarfs">Scarfs</a></li>
<li><a href="/gloves">Gloves</a></li>
</ul>
</li>
<li>
<a href="/employees" aria-haspopup="true">Employees</a>
<ul aria-hidden="true" aria-expanded="false" aria-label="employees submenu">
<li><a href="/sam">Sam</a></li>
<li><a href="/sarah">Sarah</a></li>
<li><a href="/john">John</a></li>
<li><a href="/marie">Marie</a></li>
</ul>
</li>
<li><a href="/contact">Contact</a></li>
</ul>
Add JavaScript to open the submenu on focus
As mentioned above we have to extend our nested navigation with a little bit of JavaScript. As always, there are many ways to achieve our goal. This one will work. But we could of course enhance it further. That's really up to you.
<script type="text/javascript">
if (!Element.prototype.closest) {
Element.prototype.closest = function(s) {
var el = this;
if (!document.documentElement.contains(el)) return null;
do {
if (el.matches(s)) return el;
el = el.parentElement || el.parentNode;
} while (el !== null && el.nodeType === 1);
return null;
};
}
/*
/ walk through all links
/ watch out whether they have an 'aria-haspopup'
/ as soon as a link has got the 'focus' (also key), then:
/ set nested UL to 'display:block;'
/ set attribute 'aria-hidden' of this UL to 'false'
/ and set attribute 'aria-expanded' to 'true'
*/
var opened;
// resets currently opened list style to CSS based value
// sets 'aria-hidden' to 'true'
// sets 'aria-expanded' to 'false'
function reset() {
if (opened) {
opened.style.display = '';
opened.setAttribute('aria-hidden', 'true');
opened.setAttribute('aria-expanded', 'false');
}
}
// sets given list style to inline 'display: block;'
// sets 'aria-hidden' to 'false'
// sets 'aria-expanded' to 'true'
// stores the opened list for later use
function open(el) {
el.style.display = 'block';
el.setAttribute('aria-hidden', 'false');
el.setAttribute('aria-expanded', 'true');
opened = el;
}
// event delegation
// reset navigation on click outside of list
document.addEventListener('click', function(event) {
if (!event.target.closest('[aria-hidden]')) {
reset();
}
});
// event delegation
document.addEventListener('focusin', function(event) {
// reset list style on every focusin
reset();
// check if a[aria-haspopup="true"] got focus
var target = event.target;
var hasPopup = target.getAttribute('aria-haspopup') === 'true';
if (hasPopup) {
open(event.target.nextElementSibling);
return;
}
// check if anchor inside sub menu got focus
var popupAnchor = target.parentNode.parentNode.previousElementSibling;
var isSubMenuAnchor = popupAnchor && popupAnchor.getAttribute('aria-haspopup') === 'true';
if (isSubMenuAnchor) {
open(popupAnchor.nextElementSibling);
return;
}
})
</script>
This script does everything we talked about earlier. It will take away the display:block;
on focus of the parent link (e.g. products) so the submenu will be displayed. What was hidden becomes unhidden (aria-hidden="true"
> aria-hidden="false"
) and what was unexpanded will be expanded (aria-expanded="false"
> aria-expanded="true"
).
What others say
Let's have a look at how some screen readers will handle our nested navigation.
VoiceOver
Top level: menu popup link, products
As soon as the user reaches the submenu and he hits the right arrow key: products submenu, expanded, group
Every link within the submenu simply gets announced as: hats, link
ChromeVox
Top level: list item, products, link has popup
As soon as the user reaches the submenu: list expanded with three items, hats, link list item
Narrator
Top level: products, link
As soon as the user reaches the submenu: products submenu, hats, link
NVDA
Top level: out of list link, products
As soon as the user reaches the submenu: list with three items, link, hats
Video example
The videos shows how ChromeVox, Narrator, NVDA and VoiceOver will handle our submenu example.
(Thanks to Tobias Krogh for the script.)