Navigation (Hierarchical) with Expand/Collapse
Navigation (Hierarchical) with Expand/Collapse
This pattern looks and acts like a tree, but it is NOT an ARIA tree (role="tree"
) and does NOT have the same keyboard behaviors as an ARIA tree. In this pattern, all nodes are navigable with the tab key (in ARIA trees, only one node is available to the tab key; the arrow keys are used for navigating within the tree). This pattern is simpler. It consists of nested lists with expand/collapse buttons to expose or hide child items, and standard HTML links one the final nodes of the branch.
Note:
Even though it is possible to create a navigation menu using an ARIA tree (role="tree"
), ARIA trees are not yet fully supported across all screen readers. Because site navigation is so critical to basic website operation, using ARIA trees for navigation is NOT currently recommended.
Turn on a screen reader to experience this example in action.
HTML Source Code
<!---
This component has been adapted from an example provided by the W3C, in accordance with the W3C Software and Document License https://www.w3.org/copyright/software-license-2023/
-->
<nav>
<ul class="tree-root">
<li><a href="#home">Home</a></li>
<li>
<div id="expanderGroup" class="expander">
<button type="button" aria-expanded="false" class="expander-trigger"
aria-controls="sect1" id="expander1id" >
<span class="expander-title"> School Activities <span class="expander-icon"></span>
</span>
</button>
<div id="sect1"
role="region"
aria-labelledby="expander1id"
class="expander-panel " hidden>
<ul>
<li>
<div id="expanderGroupPerf" class="expander">
<button type="button" aria-expanded="false" class="expander-trigger"
aria-controls="perf-submenu" id="expanderPerf" >
<span class="expander-title"> Performance<span class="expander-icon"></span>
</span>
</button>
<div id="perf-submenu" role="region" aria-labelledby="expanderPerf" class="expander-panel " hidden>
<ul>
<li><a href="#band">Band</a></li>
<li><a href="#choir">Choir</a></li>
<li><a href="#theater">Theater</a></li>
</ul>
</div>
</div>
</li>
<li>
<div id="expanderGroupSports" class="expander">
<button type="button" aria-expanded="false" class="expander-trigger"
aria-controls="sports-submenu" id="expanderSports" >
<span class="expander-title"> Sports<span class="expander-icon"></span>
</span>
</button>
<div id="sports-submenu" role="region" aria-labelledby="expanderSports" class="expander-panel " hidden>
<ul>
<li>
<div id="expanderGroupFall" class="expander">
<button type="button" aria-expanded="false" class="expander-trigger"
aria-controls="fall-submenu" id="expanderFall" >
<span class="expander-title"> Fall<span class="expander-icon"></span>
</span>
</button>
<div id="fall-submenu" role="region" aria-labelledby="expanderFall" class="expander-panel " hidden>
<ul>
<li><a href="#football">Football</a></li>
<li><a href="#soccer">Soccer</a></li>
<li><a href="#crossCountry">Cross Country</a></li>
<li><a href="#cheerleading">Cheerleading</a></li>
</ul>
</div>
</div>
</li>
<li>
<div id="expanderGroupWinter" class="expander">
<button type="button" aria-expanded="false" class="expander-trigger"
aria-controls="winter-submenu" id="expanderWinter" >
<span class="expander-title"> Winter<span class="expander-icon"></span>
</span>
</button>
<div id="winter-submenu" role="region" aria-labelledby="expanderWinter" class="expander-panel " hidden>
<ul>
<li><a href="#basketball">Basketball</a></li>
<li><a href="#wrestling">Wrestling</a></li>
<li><a href="#cheerleading">Cheerleading</a></li>
</ul>
</div>
</div>
</li>
<li>
<div id="expanderGroupSpring" class="expander">
<button type="button" aria-expanded="false" class="expander-trigger"
aria-controls="spring-submenu" id="expanderSpring" >
<span class="expander-title"> Spring<span class="expander-icon"></span>
</span>
</button>
<div id="spring-submenu" role="region" aria-labelledby="expanderSpring" class="expander-panel " hidden>
<ul>
<li><a href="#baseball">Baseball</a></li>
<li><a href="#track">Track</a></li>
</ul>
</div>
</div>
</li>
</ul>
</div>
</div>
</li>
</ul>
</div>
</div>
</li>
</ul>
</nav>
JavaScript Source Code
var tabExpanders = [];
class TabExpander {
static openedEle = null;
constructor(domNode, multiple ) {
if (typeof multiple == "undefined") multiple=true;
if (typeof multiple == "boolean") this.multiple = multiple;
this.rootEl = domNode;
this.buttonEl = this.rootEl.querySelector('button[aria-expanded]');
if ( this.buttonEl == null)
{
console.log("button and/or aria-expanded attribute is missing.");
return null;
}
const controlsId = this.buttonEl.getAttribute('aria-controls');
this.contentEl = document.getElementById(controlsId);
this.open = this.buttonEl.getAttribute('aria-expanded') === 'true';
this.closeActiveTab();
// add event listeners
this.buttonEl.addEventListener('click', this.onButtonClick.bind(this));
}
onButtonClick() {
this.toggle(!this.open);
}
toggle(open) {
// don't do anything if the open state doesn't change
if (open === this.open) {
return;
}
// update the internal state
this.open = open;
// handle DOM updates
this.buttonEl.setAttribute('aria-expanded', `${open}`);
if (open) {
this.contentEl.removeAttribute('hidden');
} else {
this.contentEl.setAttribute('hidden', '');
}
this.closeActiveTab();
}
closeActiveTab()
{
if (this.multiple ) return;
if(this.open)
{
if( TabExpander.openedEle != null )
{
TabExpander.openedEle.contentEl.setAttribute('hidden', '');
TabExpander.openedEle.open = false;
TabExpander.openedEle.buttonEl.setAttribute('aria-expanded', 'false');
}
TabExpander.openedEle = this;
}
else
{
if(this == TabExpander.openedEle)
TabExpander.openedEle = null;
}
}
closeOthers()
{
const expanders = document.querySelectorAll('.expander h2');
tabExpanders.forEach((expanderEl) => {
let tab = expanderEl;
if (tab.contentEl.id !== this.contentEl.id)
tab.close();
});
}
// Add public open and close methods for convenience
open() {
this.toggle(true);
}
close() {
this.toggle(false);
}
}
// init expanders
/*
const expanders = document.querySelectorAll('.expander');
expanders.forEach((expanderEl) => {
var tab = new TabExpander(expanderEl);
tabExpanders.push(tab);
});
*/
class TreeViewNavigation {
constructor(node, options) {
// Check whether node is a DOM element
if (typeof node !== 'object') {
return;
}
this.treeNode = node;
this.navNode = node.parentElement;
if (typeof options == "undefined") options = [];
if (!Array.isArray(options)) options = [];
if(typeof options["mode"] === "number")
{
var val = options["mode"];
if( val == MAX_HEIGHT_MODE)
{
if(typeof options["max_height"] === "number")
{
this.type = val;
this.maxHeight = options["max_height"];
this.setMaxHeightMode(this.maxHeight);
}
}
else
this.setAutoMode();
}
// init expanders
const expanders = this.treeNode.querySelectorAll('.expander');
expanders.forEach((expanderEl) => {
var tab = new TabExpander(expanderEl);
tabExpanders.push(tab);
});
}
setMaxHeightMode(maxHeight)
{
this.treeNode.style.maxHeight = maxHeight+"px";
this.treeNode.style.overflow = "auto";
}
setAutoMode()
{
this.treeNode.style.maxHeight = "";
this.treeNode.style.overflow = "";
}
}
const AUTO_HEIGHT_MODE = 1;
const MAX_HEIGHT_MODE = 2;
var treeViews = [];
window.addEventListener('load', function () {
var trees = document.querySelectorAll('.tree-root');
var options = [];
options["mode"] = MAX_HEIGHT_MODE;
options["max_height"] = 200;
for (let i = 0; i < trees.length; i++) {
//following line sets TreeviewNavigation in auto mode by default
//treeViews.push( new TreeViewNavigation(trees[i]));
//following line sets TreeviewNavigation in MAX_HEIGHT_MODE mode
treeViews.push(new TreeViewNavigation(trees[i], options));
}
});
//This component has been adapted from an example provided by the W3C, in accordance with the W3C Software and Document License https://www.w3.org/copyright/software-license-2023/
CSS Source Code
/*
This component has been adapted from an example provided by the W3C, in accordance with the W3C Software and Document License https://www.w3.org/copyright/software-license-2023/
*/
nav {
width: 300px;
}
.expander {
margin: 0;
padding: 0;
}
.expander h2 {
margin: 0;
padding: 0;
}
.expander:focus-within {
border-color: hsl(216deg 94% 43%);
}
.expander:focus-within h2 {
background-color: hsl(0deg 0% 97%);
}
.expander > * + * {
border-top: 1px solid hsl(0deg 0% 52%);
}
.expander-trigger {
display: block;
font-weight: bold;
width: 100%;
margin: 0;
position: relative;
padding: 5px;
padding-left: 0px;
text-align: left;
outline: none;
font-family: Lucida Grande;
font-size: 15px;
}
.expander-trigger:focus,
.expander-trigger:hover {
background: white;
}
.expander-trigger:focus {
/* outline: 4px solid transparent; */
}
.expander > *:first-child .expander-trigger,
.expander > *:first-child {
border-radius: 5px 5px 0 0;
}
.expander > *:last-child .expander-trigger,
.expander > *:last-child {
border-radius: 0 0 5px 5px;
}
button {
border-style: none;
}
.expander button::-moz-focus-inner {
border: 0;
}
.expander-title {
display: block;
pointer-events: none;
/* border: transparent 2px solid; */
border-radius: 5px;
padding: 0.25em;
outline: none;
}
.expander-trigger:focus .expander-title {
border-color: hsl(216deg 94% 43%);
}
.expander-icon {
border: solid currentcolor;
border-width: 0 2px 2px 0;
height: 0.5rem;
pointer-events: none;
position: absolute;
right: 2em;
top: 50%;
transform: translateY(-60%) rotate(45deg);
width: 0.5rem;
}
.expander-trigger:focus .expander-icon,
.expander-trigger:hover .expander-icon {
border-color: hsl(216deg 94% 43%);
}
.expander-trigger[aria-expanded="true"] .expander-icon {
transform: translateY(-50%) rotate(-135deg);
}
.expander-panel {
margin: 0;
padding: 2px 10px;
line-height: 1.7em;
/* padding: 1em 1.5em; */
}
/* For Edge bug https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/4806035/ */
.expander-panel[hidden] {
display: none;
}
.expander div.tab:focus {
background-color: #566ac8;
/* outline: 2px solid #8cc63f; */
outline: 3px solid #467310;
}
.expander div.tab {
background: #375898;
border: 1px solid #fff;
color: #fff;
padding: 3px 6px;
}
.tablist div.tab.active {
font-weight: bold;
}
.expander-panel .inactive {
display: none;
}
ul { list-style-type: none; padding: 0px; background-color: #eeeeee; font-family: Lucida Grande; font-size: 15px; }
li { padding: 5px; padding-right: 0px; }