A11Y Solutions
Provides auto-suggestions when entering text

    Dropdowns

    Also known as fly-out or navigation menus, dropdowns are a common design pattern for showing subnavigation especially in a primary navigation.

    The Problem

    CSS-only show/hide of subnavigation items work for mouse and basic keyboard support, but does not have all necessary accessibility considerations.

    The Solution

    Layering on a bit more consideration with JavaScript, we can fully support advanced keyboard navigation including esc and arrow keys as well as ARIA support.

    Related Articles

    Live Examples

    Before illustrates the problem, After illustrates the solution. Click the header to see it larger in a modal.

    Please note that this Before example contains inaccessible code, use this link to skip to the After Live Example

    before

    after

    Code Comparison

    Code diff between the before and after examples above to show the changes necessary. To copy the final source click on the 'after' path link before the diff.

    source

    Comparing /examples/navigation/dropdowns/before/index.html to /examples/navigation/dropdowns/after/index.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <meta http-equiv="X-UA-Compatible" content="ie=edge" />
    <title>Accessibility Solutions - Navigation - Dropdowns</title>
    <link rel="stylesheet" href="../../../presentation.css" />
    <link rel="stylesheet" href="dropdowns.css" />
    </head>
    <body>
    <header>
    <nav>
    <ul class="disclosure-nav">
    <li><a href="#">Lorem</a></li>
    <li>
    <button
    class="link"
    aria-expanded="true"
    aria-controls="architecto-dropdown"
    >
    Architecto
    </button>
    <ul id="architecto-dropdown">
    <li><a href="#">Quas</a></li>
    <li><a href="#">Nobis</a></li>
    <li><a href="#">Eligendi</a></li>
    </ul>
    </li>
    <li>
    <button
    class="link"
    aria-expanded="true"
    aria-controls="quaerat-dropdown"
    >
    Quaerat
    </button>
    <ul id="quaerat-dropdown">
    <li><a href="#">Exercitationem</a></li>
    <li><a href="#">Tempore</a></li>
    <li><a href="#">Amet</a></li>
    </ul>
    </li>
    </ul>
    </nav>
    </header>
    <main id="main">
    <p>
    Lorem ipsum dolor sit amet consectetur <a href="#">adipisicing elit</a>.
    Velit ea optio est adipisci beatae alias porro voluptatibus culpa dicta
    veniam, esse quidem nihil odio voluptatem error tempora nostrum minima
    magni et. Deleniti possimus corrupti mollitia cupiditate totam facere
    molestias, sunt amet exercitationem inventore odio veritatis eaque
    doloribus esse tenetur officia, odit hic obcaecati harum nostrum
    blanditiis ratione, fuga in? Dolorem tempore nemo saepe et tenetur nobis
    iste. Atque sequi a aliquid corrupti, ut fugit magni, laboriosam quo
    quidem fuga incidunt nemo modi consequatur distinctio quisquam provident
    impedit veritatis quam corporis recusandae. Amet delectus voluptatem
    ipsum earum debitis cupiditate iure, libero perferendis non eius
    explicabo porro minus temporibus nostrum corrupti iste. Accusamus,
    pariatur repudiandae! Exercitationem hic in deleniti culpa, rerum
    repellendus iure dolore blanditiis quisquam maiores, ipsa temporibus
    illum, sapiente officia repellat dignissimos minima beatae quaerat?
    Fugiat, molestiae sequi. Nulla assumenda quisquam, enim itaque
    voluptatem eum sed, suscipit doloribus ex officiis debitis vitae est.
    Excepturi ea, dicta expedita explicabo veritatis animi aspernatur quae,
    at molestias amet quasi ipsum ut labore totam ullam id accusamus, sit
    dolorem nihil repellendus? Excepturi et facere totam quibusdam vitae
    rerum, nulla a ut eveniet! Placeat dolor soluta consectetur praesentium
    obcaecati doloribus quidem consequuntur aliquam incidunt! Vel!
    </p>
    </main>

    <script src="dropdowns.js"></script>
    </body>
    </html>

    styles

    Comparing /examples/navigation/dropdowns/before/dropdowns.css to /examples/navigation/dropdowns/after/dropdowns.css

    .disclosure-nav {
    display: flex;
    list-style-type: none;
    padding: 0;
    }

    .disclosure-nav > li a,
    .disclosure-nav > li .link {
    display: flex;
    line-height: 1;
    padding: 10px;
    }

    .disclosure-nav ul {
    background-color: #fff;
    display: block;
    list-style-type: none;
    margin: 0;
    min-width: 200px;
    padding: 0;
    position: absolute;
    transform-origin: top center;
    transition: all 0.3s ease;
    }

    .disclosure-nav li ul {
    opacity: 0;
    visibility: hidden;
    transform: rotateX(-90deg);
    }

    .disclosure-nav li:hover ul,
    .disclosure-nav li:focus-within ul
    ,
    .disclosure-nav ul.nav-opened
    {
    opacity: 1;
    transform: rotateX(0);
    visibility: visible;
    }

    .disclosure-nav li {
    margin: 0;
    }

    .disclosure-nav ul a {
    border: 0;
    display: block;
    line-height: 1;
    margin: 0;
    padding: 0.5em 1em;
    }

    .disclosure-nav button {
    align-items: center;
    background: none;
    border: 0;
    border-radius: 0;
    display: flex;
    }

    .disclosure-nav button::after {
    content: '';
    border-bottom: 1px solid #000;
    border-right: 1px solid #000;
    height: 0.5em;
    margin-left: 0.5em;
    transform: rotate(45deg);
    transform-origin: right;
    width: 0.5em;
    }

    javascript

    Comparing /examples/navigation/dropdowns/before/dropdowns.js to /examples/navigation/dropdowns/after/dropdowns.js

    const DisclosureNav = function (domNode) {
    this.rootNode = domNode;
    this.triggerNodes = [];
    this.controlledNodes = [];
    this.openIndex = null;
    this.useArrowKeys = true;
    };

    DisclosureNav.prototype.init = function () {
    const buttons = this.rootNode.querySelectorAll(
    'button[aria-expanded][aria-controls]'
    );
    for (let i = 0; i < buttons.length; i++) {
    const button = buttons[i];
    const menu = button.parentNode.querySelector('ul');
    if (menu) {
    // save ref to button and controlled menu
    this.triggerNodes.push(button);
    this.controlledNodes.push(menu);

    // collapse menus
    button.setAttribute('aria-expanded', 'false');
    this.toggleMenu(menu, false);

    // attach event listeners
    menu.addEventListener('keydown', this.handleMenuKeyDown.bind(this));
    menu.addEventListener('mouseout', this.handleBlur.bind(this));
    menu.addEventListener(
    'transitionend',
    this.handleTransitionEnd.bind(this)
    );

    button.addEventListener('click', this.handleButtonClick.bind(this));
    button.addEventListener('keydown', this.handleButtonKeyDown.bind(this));
    button.addEventListener('mouseover', this.handleButtonHover.bind(this));
    button.addEventListener('mouseout', this.handleButtonBlur.bind(this));
    }
    }

    this.rootNode.addEventListener('focusout', this.handleBlur.bind(this));
    this.rootNode.addEventListener('mouseout', this.handleBlur.bind(this));
    };

    DisclosureNav.prototype.toggleMenu = function (domNode, show, timer = 500) {
    if (domNode) {
    if (show) {
    domNode.classList.add('nav-opening');
    domNode.classList.add('nav-opened');
    clearTimeout(timer);
    } else {
    timer = setTimeout((event) => {
    domNode.classList.remove('nav-opened');
    }, timer);
    }
    }
    };

    DisclosureNav.prototype.toggleExpand = function (index, expanded, timer = 500) {
    // close open menu, if applicable
    if (this.openIndex !== index) {
    this.toggleExpand(this.openIndex, false, 0);
    }

    // handle menu at called index
    if (this.triggerNodes[index]) {
    this.openIndex = expanded ? index : null;
    this.triggerNodes[index].setAttribute('aria-expanded', expanded);
    this.toggleMenu(this.controlledNodes[index], expanded, timer);
    }
    };

    DisclosureNav.prototype.controlFocusByKey = function (
    keyboardEvent,
    nodeList,
    currentIndex
    ) {
    switch (keyboardEvent.key) {
    case 'ArrowUp':
    case 'ArrowLeft':
    keyboardEvent.preventDefault();
    if (currentIndex > -1) {
    const prevIndex = Math.max(0, currentIndex - 1);
    nodeList[prevIndex].focus();
    }
    break;
    case 'ArrowDown':
    case 'ArrowRight':
    keyboardEvent.preventDefault();
    if (currentIndex > -1) {
    const nextIndex = Math.min(nodeList.length - 1, currentIndex + 1);
    nodeList[nextIndex].focus();
    }
    break;
    case 'Home':
    keyboardEvent.preventDefault();
    nodeList[0].focus();
    break;
    case 'End':
    keyboardEvent.preventDefault();
    nodeList[nodeList.length - 1].focus();
    break;
    }
    };

    /* Event Handlers */
    DisclosureNav.prototype.handleBlur = function (event) {
    const menuContainsFocus = this.rootNode.contains(event.relatedTarget);
    if (!menuContainsFocus && this.openIndex !== null) {
    this.toggleExpand(this.openIndex, false);
    }
    };

    DisclosureNav.prototype.handleButtonKeyDown = function (event) {
    const targetButtonIndex = this.triggerNodes.indexOf(document.activeElement);

    // close on escape
    if (event.key === 'Escape') {
    this.toggleExpand(this.openIndex, false);
    }

    // move focus into the open menu if the current menu is open
    else if (
    this.useArrowKeys &&
    this.openIndex === targetButtonIndex &&
    event.key === 'ArrowDown'
    ) {
    event.preventDefault();
    this.controlledNodes[this.openIndex].querySelector('a').focus();
    }

    // handle arrow key navigation between top-level buttons, if set
    else if (this.useArrowKeys) {
    this.controlFocusByKey(event, this.triggerNodes, targetButtonIndex);
    }
    };

    DisclosureNav.prototype.handleButtonClick = function (event) {
    const button = event.target;
    const buttonIndex = this.triggerNodes.indexOf(button);
    const buttonExpanded = button.getAttribute('aria-expanded') === 'true';
    this.toggleExpand(buttonIndex, !buttonExpanded);
    };

    DisclosureNav.prototype.handleButtonBlur = function (event) {
    const menuContainsFocus = this.rootNode.contains(event.relatedTarget);
    if (!menuContainsFocus && this.openIndex !== null) {
    this.toggleExpand(this.openIndex, false);
    }
    };

    DisclosureNav.prototype.handleButtonHover = function (event) {
    const button = event.target;
    const buttonIndex = this.triggerNodes.indexOf(button);
    this.toggleExpand(buttonIndex, true);
    };

    DisclosureNav.prototype.handleMenuKeyDown = function (event) {
    if (this.openIndex === null) {
    return;
    }

    const menuLinks = Array.prototype.slice.call(
    this.controlledNodes[this.openIndex].querySelectorAll('a')
    );
    const currentIndex = menuLinks.indexOf(document.activeElement);

    // close on escape
    if (event.key === 'Escape') {
    this.triggerNodes[this.openIndex].focus();
    this.toggleExpand(this.openIndex, false);
    }

    // handle arrow key navigation within menu links, if set
    else if (this.useArrowKeys) {
    this.controlFocusByKey(event, menuLinks, currentIndex);
    }
    };

    DisclosureNav.prototype.handleTransitionEnd = function (event) {
    // Remove the .nav-opening class when the menu is fully closed (tested via opacity)
    if (event.propertyName === 'opacity') {
    const compStyles = window.getComputedStyle(event.target);
    if (compStyles.opacity === 0) {
    event.target.classList.remove('nav-opening');
    }
    }
    };

    /* Initialize Disclosure Menus */

    window.addEventListener(
    'load',
    function () {
    const menus = document.querySelectorAll('.disclosure-nav');
    const disclosureMenus = [];

    for (let i = 0; i < menus.length; i++) {
    disclosureMenus[i] = new DisclosureNav(menus[i]);
    disclosureMenus[i].init();
    }
    },
    false
    );