Accessible Navigation with Drupal Core’s Menu System

person using a laptop

New to Drupal 8.9 and 9.0 is the ability to create the HTML <button> element within a native Drupal menu that can be used to toggle secondary menus (such as drop-downs or mega-menus) in a usable and accessible way.

Common inaccessible menu patterns

It's common to see links (instead of buttons) used to toggle submenus. The result of this pattern is typically inaccessible for keyboard navigation and assistive devices such as screen readers.

<!-- Inaccessible pattern -->
<ul class="menu">
  <li class="menu__item">
    <a href="#" class="menu__link">Services</a>
    <ul class="menu menu--level-2"><!-- Submenu items --></ul>
  </li>
  <!-- More top level menu items -->
</ul>

Hyperlinks (<a> tags) should only be used when navigating to a route where the URL will change. If clicking the element shows or hides something or performs some other action, a <button> element should be used. For a more nuanced explanation, see Marcy Sutton's article, Links vs. Buttons in Modern Web Applications.

Creating buttons using the Drupal admin interface

As stated earlier, Drupal 8.9 and later include support for the button element when creating menu items within Drupal's menu interface. To do so, simply enter route:<button> into the link field when creating the menu entry.

Add menu link form

Once entered, the menu item will be output as a <button> element instead of an <a> tag. However, your work is not yet done. Assuming that you're going to create a submenu, you need to make the menu respond to click and hover events in an accessible manner.

Attaching appropriate aria labels to the menu items

To indicate the current state of the <button> element, you need to attach the aria-expanded attribute. In its default closed state, it should be set to false, and then toggle to true when the submenu is open.

<li class="menu__item">
  <button aria-expanded="false">Services</button>
  <ul class="menu menu--level-2"><!-- secondary menu items --></ul>
</li>

In addition to indicating the state, you need to create a relationship between the parent <button> element and its child <ul> submenu element. To do this, you first need to create a unique id attribute on the submenu's <ul> element and then create an aria-controls attribute on the <button> element that corresponds to the submenu's id attribute. 

<li class="menu__item">
  <button aria-expanded="false" aria-controls="services-submenu">Services</button>
  <ul id="services-submenu" class="menu menu--level-2"><!-- secondary menu items --></ul>
</li>

All of this can be done within the menu's Twig template. However, the aria-attributes should not be created by Twig because you can't be sure that JavaScript is enabled, and the functionality will work properly. These attributes will be added later in the JS.

The menu.html.twig template below is adapted from the new Olivero theme. This template creates these relationships and adds some useful CSS class names for styling the links and buttons.

{#
/**
 * @file
 * Theme implementation for a menu.
 *
 * Available variables:
 * - menu_name: The machine name of the menu.
 * - items: A nested list of menu items. Each menu item contains:
 *   - attributes: HTML attributes for the menu item.
 *   - below: The menu item child items.
 *   - title: The menu link title.
 *   - url: The menu link url, instance of \Drupal\Core\Url
 *   - localized_options: Menu link localized options.
 *   - is_expanded: TRUE if the link has visible children within the current
 *     menu tree.
 *   - is_collapsed: TRUE if the link has children within the current menu tree
 *     that are not currently visible.
 *   - in_active_trail: TRUE if the link is in the active trail.
 *
 * @ingroup themeable
 */
#}
{% import _self as menus %}
{#
  We call a macro which calls itself to render the full tree.
  @see https://twig.symfony.com/doc/1.x/tags/macro.html
#}
{% set attributes = attributes.addClass('menu') %}
{{ menus.menu_links(items, attributes, 0) }}
{% macro menu_links(items, attributes, menu_level) %}
  {% set primary_nav_level = 'menu--level-' ~ (menu_level + 1) %}
  {% import _self as menus %}
  {% if items %}
    <ul {{ attributes.addClass('menu', primary_nav_level) }}>
      {% set attributes = attributes.removeClass(primary_nav_level) %}
      {% for item in items %}
        {% if item.url.isrouted and item.url.routeName == '<nolink>' %}
          {% set menu_item_type = 'nolink' %}
        {% elseif item.url.isrouted and item.url.routeName == '<button>' %}
          {% set menu_item_type = 'button' %}
        {% else %}
          {% set menu_item_type = 'link' %}
        {% endif %}
        {% set item_classes = [
            'menu__item',
            'menu__item--' ~ menu_item_type,
            'menu__item--level-' ~ (menu_level + 1),
            item.in_active_trail ? 'menu__item--active-trail',
            item.below ? 'menu__item--has-children',
          ]
        %}
        {% set link_classes = [
            'menu__link',
            'menu__link--' ~ menu_item_type,
            'menu__link--level-' ~ (menu_level + 1),
            item.in_active_trail ? 'menu__link--active-trail',
            item.below ? 'menu__link--has-children',
          ]
        %}
        <li{{ item.attributes.addClass(item_classes) }}>
          {#
            A unique HTML ID should be used, but that isn't available through
            Twig yet, so the |clean_id filter is used for now.
            @see https://www.drupal.org/project/drupal/issues/3115445
          #}
          {% set aria_id = (item.title ~ '-submenu-' ~ loop.index )|clean_id %}
          {% if menu_item_type == 'link' or menu_item_type == 'nolink' %}
            {{ link(item.title, item.url, { 'class': link_classes }) }}
            {% if item.below %}
              {{ menus.menu_links(item.below, attributes, menu_level + 1) }}
            {% endif %}
          {% elseif menu_item_type == 'button' %}
            {{ link(link_title, item.url, {
              'class': link_classes,
              'data-ariacontrols': item.below ? aria_id : false,
              })
            }}
            {% set attributes = attributes.setAttribute('id', aria_id) %}
            {{ menus.menu_links(item.below, attributes, menu_level + 1) }}
          {% endif %}
        </li>
      {% endfor %}
    </ul>
  {% endif %}
{% endmacro %}

Show and hide the submenu with CSS

Because the submenu should not be shown until activated, you need to hide it both visually and from the accessibility tree (so assistive devices cannot see it). To do this, you can either use display: none or visibility: hidden within our CSS.

.menu--level-2 {
  visibility: hidden;
}

To show the menu when the <button> element’s aria-expanded attribute is toggled to true, use the following CSS selector.

[aria-expanded="true"] + .menu--level-2 {
  visibility: visible;
}

JavaScript to initialize and toggle the submenu

The CSS above will show the submenu when the <button>'s aria-expanded attribute is true. To set this attribute, you need JavaScript. 

Note: The JavaScript examples below use modern syntax and methods that are not compatible with Internet Explorer 11. If you support this browser, make sure that you transpile the JS and include appropriate polyfills.

Create the aria attributes

Because you don't want to have aria attributes that will indicate non-existent functionality if JS is disabled, wait for JavaScript to add the aria-attributes.

The code snippet below integrates with Drupal Behaviors to create the aria attributes for each button.

(Drupal => {
  function initSubmenu(el) {
    el.setAttribute('aria-controls', el.dataset.ariacontrols);
    el.setAttribute('aria-expanded', 'false');
  }

  Drupal.behaviors.submenu = {
    attach(context) {
      context.querySelectorAll('.menu__link--button').forEach(el => initSubmenu(el));
    },
  };
}) (Drupal);

Toggle the submenu on button click

Below, a click event listener is attached to each button, which activates a toggleSubmenu function, which flips the aria-expanded attribute.

(Drupal => {
  function initSubmenu(el) {
    el.addEventListener('click', toggleSubmenu);
    el.setAttribute('aria-controls', el.dataset.ariacontrols);
    el.setAttribute('aria-expanded', 'false');
  }
  function toggleSubmenu(e) {
    const button = e.currentTarget;
    const currentState = button.getAttribute('aria-expanded') === 'true';
    button.setAttribute('aria-expanded', !currentState);
  }
  Drupal.behaviors.submenu = {
    attach(context) {
      context.querySelectorAll('.menu__link--button').forEach(el => initSubmenu(el));
    },
  };
}) (Drupal);

Toggling the submenu on hover

It's a common requirement to show the submenu when hovering over the button with a mouse pointer. To show the menu on hover, toggle the button's aria-expanded attribute on mouseover and mouseout events attached to the button's <li> parent. Attaching the events to the parent ensures the menu stays open when the end-user moves the mouse pointer off of the button and onto its submenu.

The refactored JavaScript below expands the click event to add mouseover and mouseout events on the parent <li> item.

(Drupal => {
  /**
   * Add necessary event listeners and create aria attributes
   * @param {element} el - List item element that has a submenu.
   */
  function initSubmenu(el) {
    const button = el.querySelector('.menu-link--button');
    button.setAttribute('aria-controls', button.dataset.ariacontrols);
    button.setAttribute('aria-expanded', 'false');
    button.addEventListener('click', e => toggleSubmenu(e.currentTarget, !getButtonState(e.currentTarget)));
    el.addEventListener('mouseover', toggleSubmenu(button, true));
    el.addEventListener('mouseout', toggleSubmenu(button, false));
  }

  /**
   * Toggles the aria-expanded attribute of a given button to a desired state.
   * @param {element} button - Button element that should be toggled.
   * @param {boolean} toState - State indicating the end result toggle operation.
   */
  function toggleSubmenu(button, toState) {
    button.setAttribute('aria-expanded', toState);
  }

  /**
   * Get the current aria-expanded state of a given button.
   * @param {element} button - Button element to return state of.
   */
  function getButtonState(button) {
    return button.getAttribute('aria-expanded') === 'true';
  }

  Drupal.behaviors.submenu = {
    attach(context) {
      context.querySelectorAll('.menu-item--has-children').forEach(el => initSubmenu(el));
    },
  };
}) (Drupal);

Non-JavaScript fallback

The functionality to show and hide the submenus only works if JavaScript is enabled. Fortunately, you can provide a non-JS alternative with the CSS :focus-within and :hover pseudo-classes. If any element within the <li> receives focus or is hovered over, the submenu will appear.

The selectors below take advantage of the fact that Drupal will attach a js CSS class to the HTML element when JavaScript is enabled. 

Note: The :focus-within pseudo-class is not supported by Internet Explorer 11 or earlier versions of Edge.

html:not(.js) .menu-item--has-children:focus-within .menu--level-2,
html:not(.js) .menu-item--has-children:hover .menu--level-2,
[aria-expanded="true"] + .menu--level-2 {
  visibility: visible;
}

Conclusion

Drupal front-end developers used to jump through hoops to create <button> elements within Drupal's menu structure, but with the advent of this feature, it's easier than ever before. Using patterns similar to those above, you can create super accessible and WCAG compliant menus that are easy for all users to navigate.

Special thanks to the Drupal core accessibility maintainers for helping me navigate and learn techniques such as these throughout the creation of the core Olivero theme.

Mike Herchel

Thumbnail
A senior front-end developer, Mike is also a lead of the Drupal 9 core "Olivero" theme initiative, organizer for Florida DrupalCamp, maintainer for the Drupal Quicklink module, and an expert hammocker