Fly-out Menus

Status: This is not ready for detailed review. It is an in-progress, unapproved editor’s draft.

If the user should be able to access pages deep in the website’s structure, fly-out menus are frequently used to archive the desired effect. Such fly-out menus are often called dropdown menus.

As interactive components, fly-out menus need to be developed with accessibility in mind to make sure that they are operable using the keyboard as well. Hiding menu items not displayed from keyboards and assistive technologies makes sure that the menu can be easily navigated. For people with reduced dexterity it is also important that submenus don’t snap back immediately when the mouse leaves the clickable area.

Usually the first-level menu items are links to individual pages whether they have a submenu or not. The submenu should then be duplicated as a secondary navigation on the linked web page to make sure that those pages are quickly reachable from there. Submenus are individual lists (<ul> or <ol>), nested in the parent’s list item (<li>).

Items containing a submenu should be marked in a way that is obvious. In the following example, the SpaceBears menu item has a submenu:

Example:
Code snippet: HTML
<nav role="navigation" aria-label="Main Navigation">
    <ul>
        <li><a href="…">Home</a></li>
        <li><a href="…">Shop</a></li>
        <li class="has-submenu">
            <a href="…">SpaceBears</a>
            <ul>
                <li><a href="…">SpaceBear 6</a></li>
                <li><a href="…">SpaceBear 6 Plus</a></li>
            </ul>
        </li>
        <li><a href="…">MarsCars</a></li>
        <li><a href="…">Contact</a></li>
    </ul>
</nav>

For mouse users, hiding the submenu until the mouse hovers over the first-level menu item is quite easy, but has the disadvantage that the menu immediately closes once the mouse leaves the list item (and the containing submenu).

Code snippet: CSS
nav > ul li ul {
  display: none;
}
nav > ul li:hover ul {
  display: block;
}

Enhancing the menu using JavaScript

By using JavaScript, it is possible to react to keyboard usage and abrupt mouse movements. As soon as the mouse leaves the menu a timer is started which closes the menu after one second. If the mouse re-enters the submenu again, the timer is canceled.

Improve mouse support

Example:
Code snippet: JavaScript
Array.prototype.forEach.call(menuItems, function(el, i){
  el.addEventListener("mouseover", function(event){
    this.className = "has-submenu open";
    clearTimeout(timer);
  });
  el.addEventListener("mouseout", function(event){
    timer = setTimeout(function(event){
      document.querySelector(".has-submenu.open").className = "has-submenu";
    }, 1000);
  });
});

Improve keyboard support

To improve Keyboard support, the decision has to be made if the top-level menu item should serve as a toggle for the menu for all users or be a link itself. Don’t just open the submenu as soon as the focus enters the parent menu item, as that would mean a keyboard user tediously needs to step through all the submenu links to get to the next top-level item.

Toggle submenu using the top-level menu item

The activation of the top-level menu item won’t link to the page in its href attribute but instead show the sub menu. A script stops the browser from following the link to the page. If the focus leaves the submenu (for example by using the tab key on the last submenu item), the submenu needs to close.

Example:

The following code iterates through all menu items and adds click event to the first (top-level) link in each menu item. The click event fires regardless of input method as soon as the link gets activated. If the submenu is closed when the link is activated, the script opens the submenu, and vice versa.

Code snippet: JavaScript
Array.prototype.forEach.call(menuItems, function(el, i){
  el.querySelector('a').addEventListener("click",  function(event){
    if (this.parentNode.className == "has-submenu") {
      this.parentNode.className = "has-submenu open";
    } else {
      this.parentNode.className = "has-submenu";
    }
    event.preventDefault();
    return false;
  });
});

Toggle submenu using a special “show submenu” button

If the top-level menu item should stay a link to a page, adding a separate button that toggles the submenu is the most reliable way to address the issue.

Example:

In the following code, a button is attached to every menu item link with a submenu. The click event listener is applied to this button and toggles the menu. The invisible button text is changed from show to hide submenu according to the state of the submenu.

Code snippet: JavaScript
Array.prototype.forEach.call(menuItems, function(el, i){
  var btn = '<button><span><span class="visuallyhidden">show submenu</span></span></button>';
  var topLevelLink = el.querySelector('a');
  topLevelLink.innerHTML = topLevelLink.innerHTML + ' ' + btn;

  el.querySelector('a button').addEventListener("click",  function(event){
    if (this.parentNode.parentNode.className == "has-submenu") {
      this.parentNode.parentNode.className = "has-submenu open";
      this.querySelector('.visuallyhidden').innerText = 'hide submenu';
    } else {
      this.parentNode.parentNode.className = "has-submenu";
      this.querySelector('.visuallyhidden').innerText = 'show submenu';
    }
    event.preventDefault();
  });
});

Improve screen reader support using WAI-ARIA

Currently, screen reader users are unable to tell if an item has a submenu or not and if it is opened. WAI-ARIA helps to convey this information by adding the following two attributes to the menu’s HTML:

  • aria-haspopup="true" is used so screen readers are able to announce that the link has a submenu.
  • aria-expanded is initially set to false but changed to true when the submenu opens which forces screen readers to announce that this menu item is now expanded.
Example:

Web application menus

There are some WAI-ARIA roles that are helping assistive technology to interpret menus like the ones found in desktop software. When using the menu WAI-ARIA attributes, the keyboard interaction should be similar to desktop software as well: the tab key is used to iterate through the top-level items only, the up and down arrows are used to navigate the sub menus. This keyboard behavior doesn’t come with the WAI-ARIA attributes, but needs to be added using scripting.

In addition to the aria-expanded and aria-haspopup attributes, the following roles are used in the example:

  • aria-menubar: Represents a usually horizontal menu bar.
  • aria-menu: Represents a set of links or commands in a menu bar, it is used for the fly-out menus.
  • aria-menuitem: Represents an individual menu item.
Example:

The markup has no links, it is a bare nested list with some WAI-ARIA roles. As we will add keyboard and mouse interaction on our own, this is enough.

Code snippet:
<div role="menubar">
  <ul role="menu" aria-label="functions" id="appmenu">
    <li role="menuitem" aria-haspopup="true">
      File
      <ul role="menu">
        <li role="menuitem">New</li>
        <li role="menuitem">Open</li>
        <li role="menuitem">Print</li>
      </ul>
    </li></ul>
</div>

First, we collect all top-level menu items in a variable (appsMenuItems) as well as all submenu items (subMenuItems). An object is defined with the key codes of the keys that need to be handled. This makes the code much more readable. Two variables keep track of the focus in top-level items (currentIndex) and in submenus (subIndex).

Code snippet:
var appsMenuItems = document.querySelectorAll('#appmenu > li');
var subMenuItems = document.querySelectorAll('#appmenu > li li');
var keys = {
  tab:     9,
  enter:  13,
  esc:    27,
  space:  32,
  left:   37,
  up:     38,
  right:  39,
  down:   40
};
var currentIndex, subIndex;

To make the menu work for keyboard users, a tabindex attribute with the value -1 is added to the menu items. This enables scripts to set the focus on the element. The first menu item (“File” in this example) is assigned a tabindex value of 0 which adds it to the tab order and lets the user access the menu. The currentIndex variable is initialized as soon as this first item gets focus.

Code snippet:
Array.prototype.forEach.call(appsMenuItems, function(el, i){
    if (0 == i) {
      el.setAttribute('tabindex', '0');
      el.addEventListener("focus", function() {
        currentIndex = 0;
      });
    } else {
      el.setAttribute('tabindex', '-1');
    }
});

Array.prototype.forEach.call(subMenuItems, function(el, i){
  el.setAttribute('tabindex', '-1');
});

All top-level menu items close open submenus when they receive focus and reset the subIndex variable. When individual items are clicked, the visibility of the submenu is toggled by changing the aria-expanded value. If a key is pressed, the appropriate action is carried out.

Key mapping for top-level menu items
Key Action
tab ⇥ Select the next top-level menu item
right →
shift ⇧ + tab ⇥ Select the previous top-level menu item
left ←
return/enter ↵ Open the submenu, select first submenu item.
space
down ↓
up ↑ Open the submenu, select last submenu item.
esc Leave the menu
Code snippet:
Array.prototype.forEach.call(appsMenuItems, function(el, i){
  /* code above */

  el.addEventListener("focus", function() {
    subIndex = 0;
    Array.prototype.forEach.call(appsMenuItems, function(el, i){
      el.setAttribute('aria-expanded', "false");
    });
  });

  el.addEventListener("click",  function(event){
    if (this.getAttribute('aria-expanded') == 'false'
        || this.getAttribute('aria-expanded') ==  null) {
      this.setAttribute('aria-expanded', "true");
    } else {
      this.setAttribute('aria-expanded', "false");
    }
    event.preventDefault();
    return false;
  });

  el.addEventListener("keydown", function(event) {
    switch (event.keyCode) {
      case keys.right:
        gotoIndex(currentIndex + 1);
        break;
      case keys.left:
        gotoIndex(currentIndex - 1);
        break;
      case keys.tab:
        if (event.shiftKey) {
          gotoIndex(currentIndex - 1);
        } else {
          gotoIndex(currentIndex + 1);
        }
        break;
      case keys.enter:
      case keys.space:
      case keys.down:
        this.click();
        subindex = 0;
        gotoSubIndex(this.querySelector('ul'), 0);
        break;
      case keys.up:
        this.click();
        var submenu = this.querySelector('ul');
        subindex = submenu.querySelectorAll('li').length - 1;
        gotoSubIndex(submenu, subindex);
        break;
      case keys.esc:
        document.querySelector('a[href="#related"]').focus();
    }
    event.preventDefault();
  });
});

Submenu items do behave differently when interacting with them on the keyboard, see the following table for details:

Key mapping for submenu items
Key Action
tab ⇥ Close the submenu, select the next top-level menu item
right →
shift ⇧ + tab ⇥ Close the submenu, select the previous top-level menu item
left ←
return/enter ↵ Carry out function of this item. (In this example: bring up a dialog box with the text of the chosen menu item.)
space
down ↓ Select next submenu item
up ↑ Select previous submenu item
esc Close the submenu, select the current top-level menu item
Code snippet:
Array.prototype.forEach.call(subMenuItems, function(el, i){
  el.setAttribute('tabindex', '-1');
  el.addEventListener("keydown", function(event) {
      switch (event.keyCode) {
        case keys.tab:
          if (event.shiftKey) {
            gotoIndex(currentIndex - 1);
          } else {
            gotoIndex(currentIndex + 1);
          }
          break;
        case keys.right:
          gotoIndex(currentIndex + 1);
          break;
        case keys.left:
          gotoIndex(currentIndex - 1);
          break;
        case keys.esc:
          gotoIndex(currentIndex);
          break;
        case keys.down:
          gotoSubIndex(this.parentNode, subIndex + 1);
          break;
        case keys.up:
          gotoSubIndex(this.parentNode, subIndex - 1);
          break;
        case keys.space:
        case keys.enter:
          alert(this.innerText);
          break;
      }
      event.preventDefault();
      event.stopPropagation();
      return false;
    });
  el.addEventListener("click", function(event) {
      alert(this.innerHTML);
      event.preventDefault();
      event.stopPropagation();
      return false;
    });
});

See the complete example code.