Menu

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.)