DEV Community

Cover image for How I approached keyboard accessibility on my site navigation
Colette Wilson
Colette Wilson

Posted on

How I approached keyboard accessibility on my site navigation

Cover photo by Jay Zhang on Unsplash

A couple of disclaimers before I start:

  • This is not a how-to. I'm not suggesting this is how keyboard accessibility should be handled, this is just an article on how I approached it.
  • I am not an expert in accessibility and there will most certainly be things I've done here that could be better.

Contents:

TL;DR
Check out my Codepen example
Check out a real world example

The Basic Markup

Let's take a look at some basic markup as a starting point. Typically, I might start off with something that looks like this;

<header class="header">
  <nav class="nav">
    <ul class="nav__list">
      <li class="nav__item has-dropdown">
        <span>Item 1</span>
        <ul class="nav__dropdown">
          <li><a href="">Sub item 1</a></li>
          <li><a href="">Sub item 2</a></li>
          <li><a href="">Sub item 3</a></li>
        </ul>
      </li>
      <li class="nav__item has-dropdown">
        <span>Item 2</span>
        <ul class="nav__dropdown">
          <li><a href="">Sub item 1</a></li>
          <li><a href="">Sub item 2</a></li>
          <li><a href="">Sub item 3</a></li>
        </ul>
      </li>
      <li class="nav__item has-dropdown">
        <span>Item 3</span>
        <ul class="nav__dropdown">
          <li><a href="">Sub item 1</a></li>
          <li><a href="">Sub item 2</a></li>
          <li><a href="">Sub item 3</a></li>
        </ul>
      </li>
      <li class="nav__item"><a href="">Item 4</a></li>
    </ul>
  </nav>
</header>

As you can see I have a list of navigation items some of which have sub items which I would like to be presented as dropdowns.

In my accessibility ignorant past I was satisfied with handling the display of those dropdowns purely through CSS simply by declaring display: none; on the dropdown then targeting the parent <li> on hover and switching to display: block;.

As I now know this approach renders those dropdowns completely inaccessible to keyboard users. How do I fix this?

Improving the Markup with Attributes

Currently there are a number of problems with the markup as it is. First off, the list items with dropdowns are not keyboard navigable. This is because some HTML elements come with built in roles, examples of these are buttons or anchor tags. Since my list items contain a span, an element that doesn't come with a role, there is no reason for the keyboard to tab to that item. Secondly, even if I could tab to these items the dropdown is only shown on hover so at the moment I can only access those dropdowns as a mouse user.

The first step in making my navigation more keyboard friendly is to add some additional markup in the form of attributes.

Firstly, let's give the nav element a label. I might have more than one type of nav element across the website, for example, the footer might contain a nav element or I might have some pagination within a nav element. It would be nice to announce what the navigation is for. To do this I'm going to add aria-label="Main".

Secondly, although <li> is an example of an element that comes with a built in role I want to specify a role that is more accurate of what these elements actually are so I'm going to add role="menuitem" to my <li>s.

Thirdly, I need to be able to tab to my <li>s. For this I'm going to add tabindex="0" to the items that contain a dropdown. Giving the attribute a value of zero allows that element to become focusable sequentially in keyboard navigation.

Lastly, I want to add a couple more attributes that it make clear that this item has a popup which is currently not expanded so I also want to add aria-haspopup="true" aria-expanded="false" to my list items containing a dropdown.

The markup for my list items now looks like this;

<li class="nav__item has-dropdown" role="menuitem" aria-haspopup="true" aria-expanded="false" tabIndex="0">
  <span>Item 1</span>
  <ul class="nav__dropdown">
    <li><a href="">Sub item 1</a></li>
    <li><a href="">Sub item 2</a></li>
    <li><a href="">Sub item 3</a></li>
  </ul>
</li>

Great, now I'm able to tab through my main navigation items but how do I get to the dropdowns?

Adding Some Javascript

At this point I should say that there is a CSS solution to this problem. By using the :focus-within pseudo-class I could add display: block; to the <li> should one of the links within that item receive focus. This may be appropriate for smaller nav structures but my project featured a structure that I felt was a little too complex and I decided it was best to employ an alternative solution.

Firstly, I want to target all the list items that have a dropdown. I could do this with const navItems = document.querySelectorAll(".nav__item.has-dropdown"), however, I prefer to separate my css from my js and to make it clear that there is javascript attached to these items I'm going to add a js specific class of js-nav-item. Now that I have them stored as a variable I can loop over them and add a keydown event listener which will invoke a function called handleKeydown. My handleKeydown function will look like this;

const handleKeydown = (evt) => {
  if (evt.keyCode === 13) {
    evt.preventDefault()
    evt.currentTarget.setAttribute("aria-expanded", "true")
    evt.currentTarget.querySelector("a").focus()
  }

  if (evt.keyCode === 27) {
    evt.currentTarget.setAttribute("aria-expanded", "false")
    evt.currentTarget.focus()
  }
}

This function listens for a key press and if the key code matches the code for the Enter key will change the value of the aria-expanded attribute to true. It will also find the first <a> within the list item and give it focus. If the key code matches the code for the Escape key it will change the value of the aria-expanded attribute to false and will return the focus to the navigation item.

Great but at this point my dropdown still won't actually display. I need to update my CSS to target the aria-expanded attribute and set display: block; when true. Like so,

.nav__item.has-dropdown[aria-expanded="true"] .nav__dropdown {
  display: block;
}

This is good progress. I can tab through my nav items, I can open the dropdown by pressing Enter, and I can close the dropdown by pressing Escape. However, although I can tab through the links in the dropdown on Chrome, Firefox will just tab to the next top-level nav item -- I need to be able to cycle through the dropdown links somehow. Also, if I tab to next top-level item without first closing the dropdown it will remain open. I also want the dropdown to close when the focus is not on it's parent.

Let's tackle the dropdown links. It would be nice if, when the dropdown is open, I could cycle through the links using the arrow keys. To do this I'm going to expand my handleKeydown function. I want to be able to select the dropdown in my js so, as before, I want to add a js specific class of js-nav-dropdown to all the dropdown elements. Now, I can get all the links within the dropdown and add a keydown event listener which will invoke a handleDropdownKeydown function. My updated handleKeydown function now looks like this;

const handleKeydown = (evt) => {
  if (evt.keyCode === 13) {
    evt.preventDefault()
    evt.currentTarget.setAttribute("aria-expanded", "true")
    evt.currentTarget.querySelector("a").focus()

    // Target dropdown and call function to cycle through dropdown links
    let dropdown = evt.currentTarget.querySelector(".js-nav-dropdown")
    handleDropdownKeydown(dropdown)
  }

  if (evt.keyCode === 27) {
    evt.currentTarget.setAttribute("aria-expanded", "false")
    evt.currentTarget.focus()
  }
}

And my handleDropdownKeydown function looks like this;

const handleDropdownKeydown = (parent) =>  {
  const links = parent.querySelectorAll("a")
  links.forEach((el, i) => {
    el.addEventListener("keydown", (evt) => {
      if (evt.keyCode === 40 || evt.keyCode === 39) {
        let next = links[i + 1] || null
        evt.preventDefault()
        next !== null && next.focus()
      }
      if (evt.keyCode === 38 || evt.keyCode === 37) {
        let prev = links[i - 1] || null
        evt.preventDefault()
        prev !== null && prev.focus()
      }
      if (evt.keyCode === 13) {
        evt.stopPropagation()
      }
    })
  })
}

In this function I am selecting all the links within the dropdown and looping over each of them to add a keydown event listener. If the keyCode for the event is 40 (up arrow) or 39 (right arrow) I want to add focus to the next link. If the keyCode is 38 (down arrow) or 37 (left arrow) I'd like to add focus to the previous link. If the keyCode is 13 (Enter key) I want the link to take me to it's destination, however, in my handleKeydown function I have prevented the default action. Because of event bubbling this means that pressing Enter when focused on a dropdown link won't do anything so I need to invoke the stopPropogation() method. Excellent, now I can cycle through the dropdown links and they will function as expected.

The very last thing I want to do is to close the dropdown if the focus moves on to another top-level nav item. To do this I want to loop over my nav items and add a focus event listener which will call a handleFocus function. handleFocus will loop over all the items and update the aria-expanded attribute to false which will close any open dropdowns.

That's pretty much it.

Top comments (0)