Link + Disclosure Widget Navigation

Early in 2017 I filed an issue against WAI-ARIA Authoring Practices (APG) requesting a change to the menu navigation pattern. Despite a great deal of feedback in agreement, it languished. In late 2017 I wrote Don’t Use ARIA Menu Roles for Site Nav and started actively campaigning against the APG pattern.

Not much has happened since then. A little good news is that use of role="menu" for web page navigation has fallen out of favor with developers who test with users.

Less than a month ago Sarah Higley issued a pull request against the APG to propose a navigation pattern. You can see a version of that pattern as the navigation bar at USA.gov.

A few days ago Marcus Herrmann wrote Menu (or not) where he outlines his own path trying to find the best way forward for a menu on a shopping cart.

These two examples have different objectives and use cases, but it also made it clear to me that I have been promoting a pattern that I have failed to write up for the broader community to review. While I have tested it with users, that is not the same as letting other practitioners take a run at it.

The Pattern

Sarah’s navigation pattern is quite similar to the one I have developed. Except in my pattern the top-level items are all links, and some of them may have more links hidden under them. I am not suggesting that hers is wrong, it is just a different pattern.

Embedded below is the navigation. You can visit it directly at Codepen, as well as a debug version which is free of Codepen cruft.

See the Pen WCAG 2.1 Compliant Navigation by Adrian Roselli (@aardrian) on CodePen.

The Container

The navigation lives in a <nav> as a list with nested lists.

<header>
  <nav id="Nav">
    <ul>
    […]
    </ul>
  </nav>
</header>

The links are regular hyperlinks. They work with a keyboard, assistive technology knows what to do with them, and links are generally well understood by users.

Note that I do two things here to identify that the current page is the About page. The first is add aria-current="page" to the link as that will convey it to screen readers. I also add class="selected" to the parent <li>, though that is strictly for styling the container in my design.

Ideally you want to use a CSS selector like a[aria-current="page"] to ensure your visual indicator stays in sync with your programmatic indicator.

      <li class="selected">
        <a href="http://example.com/?2" id="Item02" aria-current="page">About</a>
        […]
      </li>

The Disclosure Button

This pattern needs the aria-expanded attribute to work properly, where its value must be toggled from true to false to correspond to the presence of the content it is showing or hiding.

I use aria-controls to point to the id of the nested list. This creates a programmatic connection between the two. Heydon Pickering identified in 2016 that aria-controls has limitations and spotty support. Prior to that, in 2014 Léonie Watson laid out the benefits for when aria-controls does work, and given this simple pattern it does not prove to be an issue in current browsers and screen readers. For those combinations that do not support it, the navigation is not confounding.

I use an inline style to hide the nested list of links to minimize the chance that it will display while any CSS is still being processed. My JavaScript function simply toggles that value.

        <button type="button" id="btnItem02" aria-controls="SubItem02" aria-expanded="false" aria-label="Show" aria-labelledby="btnItem02 Item02" onclick="toggleSubNav(this.id);">
          […]
        </button>
        <ul id="SubItem02" style="display:none;">
          […]
        </ul>

Naming the Disclosure Button

Providing an accessible name to the disclosure button takes just a little planning. Ideally you want to lean on the text in the navigation wherever possible. In this pattern, I initially set the name of the button with aria-label.

An advantage is when the content gets localized, the button name will match. However, if you do not do your own localization already, this will not be translated by auto translation services unless you are using Google Chrome and its built-in Google Translate service. For cases where it does not translate, the aria-expanded conveys the state to a screen reader user anyway.

In my script I alternate the value of the aria-label based on whether the sub-navigation is displayed (Hide) or not (Show). This almost definitely will not be auto-translated even in Chrome since it is being injected by script.

Then the accessible name of the button is built up from the button’s own name as well as the associated text link by using aria-labelledby to point at the ids of both (aria-labelledby accepts a space-separated list of ids to build an accessible name).

        <a href="http://example.com/?2" id="Item02" aria-current="page">About</a>
        <button type="button" id="btnItem02" aria-controls="SubItem02" aria-expanded="false" aria-label="Show" aria-labelledby="btnItem02 Item02" onclick="toggleSubNav(this.id);">
          […]
        </button>

Esc Support

There is a function in this pattern that listens for the Esc key and, when pressed, hides any visible sub-navigation lists. This means a user can Tab through the navigation, show a sub-navigation list, and not have to Shift + Tab to the button that displayed it nor Tab to the next button to show another sub-navigation list.

While I am comfortable in HTML, CSS, and ARIA, JavaScript is not my strongest skill so you may want to adapt this function.

document.onkeydown = function(evt) {
  evt = evt || window.event;
  var isEscape = false;
  if ("key" in evt) {
    isEscape = evt.key == "Escape" || evt.key == "Esc";
  } else {
    isEscape = evt.keyCode == 27;
  }
  if (isEscape) {
    toggleSubNav("");
  }
};

Click-to-Close Support

From testing with users, they generally expect the sub-navigation lists to disappear when they click or tap somewhere else on the screen. I have not built that support yet. You probably should.

What ARIA I Did Not Use

I did not use any widget roles that correspond to menus, such as menubar, menu, or menuitem. If you read my original post, Don’t Use ARIA Menu Roles for Site Nav, I explain in more detail why.

The gist is that these roles switch a screen reader into a forms or application mode and tell the user what keyboard commands to use — commands which not only do not belong on a regular web page, but commands which you will now have to support.

I also do not use aria-haspopup. I hope the following explanation of the attribute gives enough context with why I do not use it.

A popup element usually appears as a block of content that is on top of other content. Authors MUST ensure that the role of the element that serves as the container for the popup content is menu, listbox, tree, grid, or dialog, and that the value of aria-haspopup matches the role of the popup container.

Styles

The styles in this navigation are beyond the scope of an accessible pattern. But I do want to call out a few things that may be useful should you implement this.

As you can see, I kind of phoned in the styles since they were not my goal here.

CSS Flex

I use flex styles to allow the navigation to expand or contract for any viewport size. I am generally opposed to hamburger menus (particularly at desktop resolutions) and feel that navigation can do just fine if you let it adapt to the viewport.

I have arbitrarily chosen 60em as the desktop width below which I allow it to wrap, and above which I make sure the last nested list does not flow outside the right edge the viewport. For between, I was looking at the Intersection Observer API to identify when anything falls out of the viewport and then brute-forcing it back.

Examples

Browser accessibility inspector screen shots and videos with NVDA, JAWS, and VoiceOver are below.

The accessibility inspectors from FIrefox and Chrome examining the same button.
The Firefox (left) and Chrome (right) accessibility inspector panels confirm that the browser is getting the correct values for the accessible name and the state of the button.
Using Firefox 68.0b3 and NVDA 2019.1.1. You do not hear it announced, but when on the About button and the Press Events sub-link I press Esc to hide the list.
Using Internet Explorer 11 and JAWS 2018.1808.10. JAWS announces the keys that you press, which is why you hear them throughout.
Using Safari and VoiceOver on macOS 10.14.3. You do not hear it announced, but when on the About button and the Services button I press Esc to hide the list.

Future

After Sarah’s navigation pattern has been accepted by APG, I plan to submit mine. Ours are close enough, but satisfy different use cases, that I have opted to let her do all the hard work of going through the APG process. I have said as much, so at least she already knows I am a terrible person.

7 Comments

Reply

I took a shot at the isEscape function, if you’re interested:

document.addEventlistener("keydown", function(evt) {
  if (evt.isComposing || evt.keyCode == 229) { return }
  if (evt.key == "Escape" || evt.key == "Esc" || evt.keyCode == 27) {
    toggleSubNav("");
  }
});
  • The window.event fallback is be necessary for truly ancient IE versions — at least, I think so. You may know better than I do — I can only test in some versions since VirtualBox can overwhelm my old laptop.
  • The isComposing/keyCode == 229 bails out early in case the keypress event is from IME composition.
  • It’s okay to test equality for nonexistent properties on the evt object — they evaluate to undefined, which is a perfectly cromulent thing to compare.
  • addEventListener() is slightly safer in that it won’t overwrite or be overwritten by any other JavaScript. This may sound paranoid, but a lot of terrible ad/analytics/other garbage scripts will ruin your day if you use onkeydown.

(Also, I can only select one line of code at a time in your code samples on MacOS Firefox. It seems to be the fault of the filter: invert(100%) as part of your new Dark Mode CSS? Weird.)

In response to Taylor Hunt. Reply

I am definitely interested, and thanks for taking the time to offer the function. Any chance you can point me to a function to also close the nested navigation for any click outside of them?

Also, I think the code selection issue is a function of how I do the animated scanline effect. I suspect I am due to revisit that.

In response to Adrian Roselli. Reply

This is the JS function I used to close the nested nav when clicking outside. Essentially it listens to a blur event, then checks if the element receiving focus is within the nav:

function handleBlur(event) {
  var menuContainsFocus = rootNode.contains(event.relatedTarget);
  if (!menuContainsFocus && isOpen) {
    closeDropdownNav();
  }
};
In response to Sarah Higley. Reply

Thanks! An update to my pen is in order!

Reply

That aria-label/labelledby pattern is some next-level stuff. :D

In response to James. Reply

Thanks. It may be too verbose for some users, but I like the flexibility.

Reply

The aria-label and aria-labelledby is an interesting combination; that could be used in other useful ways. Read more links come to mind.

The only warning I offer is for implementors to consider the words used in relation to the open / closed state of the widget—as this can sometimes cause confusion when announced by a screen reader in conjunction with the aria-expanded state.

Laurence Lewis; . Permalink

Leave a Reply to Sarah Higley Cancel response

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

This site uses Akismet to reduce spam. Learn how your comment data is processed.