DEV Community

Cover image for Accessible dropdown menus that pop up
Emma Dawson
Emma Dawson

Posted on

Accessible dropdown menus that pop up

The title of this article refers to one interaction that goes by many (sometimes confusing) names. You click a button that causes some previously hidden content to appear on the screen.

Examples may include clicking on a profile picture to see various profile options or having a multi-level navigation with top level buttons and nested links.

Do you call it a dropdown, a popup, a menu, a disclosure? A dropdown menu? A popup menu? Whatever you call it, let's look at how to make it more accessible.

The disclosure pattern

In this article, I'm going to refer to it as a disclosure. Why? Because it might not always drop down below a button and menu patterns are complicated and usually not needed on websites.

In this pattern, a user presses a button and something that was hidden is now visible - it is disclosed. Clicking away will hide the content again.

Our example

In this example, I'm going to be using a fairly basic multi-level navigation to demonstrate what's needed above and beyond semantic HTML to make the disclosure pattern accessible.

Here's what our navigation looks like:

Navigation with 3 sections. Our services, About and Contact Us. Our services and About have down arrows next to the text

It's a simple navigation with three sections: Our Services, About and Contact Us. Visually, we can see that Our Services and About are marked with down arrows suggesting that pressing these will open a disclosure. The lack of a down arrow on Contact Us suggests instead that we will be taken directly to that section.

The arrows also give us clues about the semantic HTML we should use. Pressing Our Services or About performs an action by opening a disclosure on the same page and therefore they should be buttons. Pressing Contact Us takes us to a different part of the site and should therefore be a link.

The HTML

Let's take a look at the HTML and the necessary attributes needed to get this working accessibly:

<nav>
  <ul class="nav-list">
    <li class="nav-group">
      <button id="navItem1" class="navItem" aria-expanded="false" aria-controls="disclosure1">Our Services &#9660;</button>
      <ul class="disclosure hidden" id="disclosure1">
        <li>
          <a href="#">Dropdown 1 - item 1</a>
        </li>
        <li>
          <a href="#">Dropdown 1 - item 2</a>
        </li>
        <li>
          <a href="#">Dropdown 1 - item 3</a>
        </li>
      </ul>
    </li>
    <li class="nav-group">
      <button id="navItem2" class="navItem" aria-expanded="false" aria-controls="disclosure2">About &#9660;</button>
      <ul class="disclosure hidden" id="disclosure2">
        <li>
          <a href="#">Our culture</a>
        </li>
        <li>
          <a href="#">Partnerships</a>
        </li>
      </ul>
    </li>
    <li class="nav-group">
      <a href="#">Contact Us</a>
    </li>
  </ul>
</nav>
Enter fullscreen mode Exit fullscreen mode

We use the nav element around the entire component to indicate that this is a navigation. Within that we have an unordered list ul with three list items: the Our Services and About buttons using the <button> element and the Contact Us link using the <a> element.

The buttons require more than just HTML in order to be accessible. First we add an attribute aria-expanded="false". This let's screen reader users know that the disclosure attached to the button is not currently visible. We can change it to true with JavaScript when a user clicks on the button.

Each button also has aria-controls="disclosure-id" which links to the id attribute of the disclosure that belongs to it. This helps to identify which button controls each disclosure.

Nested inside each list item for the Our Services and About sections is another unordered list of the links that will be disclosed when the button is pressed.

In their initial state, the lists have the hidden attribute which means they are not announced to screen reader users and it is not possible to access them with a keyboard whilst closed. This attribute will also be controlled with JavaScript when the button is pressed and the disclosure becomes visible.

Adding the functionality

Now let's make it work. There's several things we want our component to do:

  1. It should show the disclosure when the button is clicked once
  2. It should hide the disclosure if the same button is pressed a second time
  3. An open disclosure should close when the user activates another disclosure
  4. The disclosure should close if a user presses the Escape key
  5. The disclosure should close if a user tabs away from the last element to another element on the page

Navigation with 3 sections. Our services, About and Contact Us. The About section is selected and shows a disclosure with two links

Opening and Closing the disclosure on button press

In order to open the disclosure we need to remove the hidden attribute and change aria-expanded to true. To make sure only one disclosure is open at once we loop over all of the buttons and make sure everything is closed first. Then we open only the one that was most recently pressed.

When we close the disclosure, we add back the hidden attribute and change aria-expanded back to false.

const navButtons = document.querySelectorAll(".navItem");
const disclosures = document.querySelectorAll(".disclosure");

function openNavigation(button) {
  button.setAttribute("aria-expanded", "true");
  // The ul is a direct sibling to the button
  const disclosure = button.nextElementSibling;
  disclosure.classList.remove("hidden");
}

function closeNavigation(button) {
  button.setAttribute("aria-expanded", "false");
  const disclosure = button.nextElementSibling;
  disclosure.classList.add("hidden");
}

function toggleNavigation(index) {
  // First we close any open disclosures not related to the 
  //current button in focus by looping over all nav buttons
  navButtons.forEach((button, buttonIndex) => {
    if (buttonIndex != index) {
      closeNavigation(button);
    }
  });
  const currentButton = event.target;
  const open = currentButton.getAttribute("aria-expanded");
  open === "false"
    ? openNavigation(currentButton)
    : closeNavigation(currentButton);
}

// Adds the toggle event to every top level button
navButtons.forEach((button, index) => {
  button.addEventListener("click", () => toggleNavigation(index));
});
Enter fullscreen mode Exit fullscreen mode

Closing the disclosure with Escape

If the disclosure is open and a user presses the Escape key, we want the disclosure to disappear but, more importantly, we need to manage focus so that the user gets a good keyboard experience.

To do this we add an event listener for the Escape key, find the button which is connected to the open disclosure and use the .focus() method to send focus back to the button that originally opened the disclosure.

// This adds a global event listener to close any open disclosures when the escape key is pressed
window.addEventListener("keyup", (e) => {
  if (e.key === "Escape") {
    const navButtonsArr = Array.from(navButtons);
    const currentOpenButtonIndex = navButtonsArr.findIndex(
      (button) => button.getAttribute("aria-expanded") === "true"
    );
    // If there is an open disclosure, close it and send focus back to the button that controls it.
    if (currentOpenButtonIndex >= 0) {
      const currentOpenButton = navButtons[currentOpenButtonIndex];
      currentOpenButton.focus();
      closeNavigation(currentOpenButton);
    }
  }
});
Enter fullscreen mode Exit fullscreen mode

Closing when the user tabs away

If a user tabs away then ideally the disclosure closes so that they can see any content underneath. You might first think that an onblur event on the last element will work for this. However, the onblur event gets triggered if you are tabbing backwards as well as forwards, which would prevent users from ever tabbing backwards from the last element in the disclosure. Instead, we'll check if the item in focus is within the disclosure. If not, we'll close it.

// This function closes an open disclosure if a user tabs away from the last anchor element in the list. It is reliant on the top-level list item of the top level ul having a class to find the group containing button + disclosure it controls
function handleBlur(button) {
  const navList = event.currentTarget.closest(".nav-group");
  if (!event.relatedTarget || !navList.contains(event.relatedTarget)) {
    closeNavigation(button);
  }
}

// This adds the handleBlur event to the last anchor element in each disclosure
disclosures.forEach((disclosure) => {
  const listItems = disclosure.querySelectorAll("li a");
  listItems[listItems.length - 1].addEventListener("blur", (event) => {
    handleBlur(disclosure.previousElementSibling);
  });
});
Enter fullscreen mode Exit fullscreen mode

Final thoughts

It may seem like a lot of code for such a "simple" pattern and this is not even including the CSS to style it! It's probably not as much code when using something like React, so don't be scared away. The important thing is remembering the different fuctionality that needs to be added.

It's also important that we get used to adding in the code to make our components work for everyone, irrespective of the device they're using to access the content. And once you've done it a few times it will become second nature to add all the code needed to make every component accessible.

Top comments (2)

Collapse
 
poetro profile image
Peter Galiba

Shouldn't they have the role of menubar / menu / menuitem?

Collapse
 
emmadawsondev profile image
Emma Dawson

No, they are not menus but the word menu is often misused to describe them. The menu roles bring a lot of complexity regarding their expected behaviour which is rarely needed on the web.

Adrian Roselli has written an in-depth blog about why you should avoid menu roles adrianroselli.com/2017/10/dont-use...