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.

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.nav-closed {
opacity: 0;
visibility: hidden;
transform: rotateX(-90deg);
}

.disclosure-nav li:hover ul,
.disclosure-nav
li:focus-within ulul:not(.nav-closed) {
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

var DisclosureNav = function(domNode) {

this.rootNode = domNode;
this.triggerNodes = [];
this.controlledNodes = [];
this.openIndex = null;
this.useArrowKeys = true;
};

DisclosureNav.prototype.init = function() {
var buttons = this.rootNode.querySelectorAll(
"button[aria-expanded][aria-controls]"
);
for (var i = 0; i < buttons.length; i++) {
var button = buttons[i];
var 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));
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) {
var timer;

if (domNode) {
if (show) {
domNode.classList.remove("nav-closed");
clearTimeout(timer);
} else {
timer = setTimeout(function(event) {
domNode.classList.add("nav-closed");
}, 500);
}
}
};

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

// 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);
}
};

DisclosureNav.prototype.controlFocusByKey = function(
keyboardEvent,
nodeList,
currentIndex
) {
switch (keyboardEvent.key) {
case "ArrowUp":
case "ArrowLeft":
keyboardEvent.preventDefault();
if (currentIndex > -1) {
var prevIndex = Math.max(0, currentIndex - 1);
nodeList[prevIndex].focus();
}
break;
case "ArrowDown":
case "ArrowRight":
keyboardEvent.preventDefault();
if (currentIndex > -1) {
var 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) {
var menuContainsFocus = this.rootNode.contains(event.relatedTarget);
if (!menuContainsFocus && this.openIndex !== null) {
this.toggleExpand(this.openIndex, false);
}
};

DisclosureNav.prototype.handleButtonKeyDown = function(event) {
var 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) {
var button = event.target;
var buttonIndex = this.triggerNodes.indexOf(button);
var buttonExpanded = button.getAttribute("aria-expanded") === "true";
this.toggleExpand(buttonIndex, !buttonExpanded);
};

DisclosureNav.prototype.handleButtonBlur = function(event) {
var button = event.target;
var buttonIndex = this.triggerNodes.indexOf(button);
this.toggleExpand(buttonIndex, false);
};

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

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

var menuLinks = Array.prototype.slice.call(
this.controlledNodes[this.openIndex].querySelectorAll("a")
);
var 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);
}
};

/* Initialize Disclosure Menus */

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

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