Skip to content

Instantly share code, notes, and snippets.

@wizard04wsu
Last active July 1, 2020 17:58
Show Gist options
  • Save wizard04wsu/b691c49af0f03378aec1 to your computer and use it in GitHub Desktop.
Save wizard04wsu/b691c49af0f03378aec1 to your computer and use it in GitHub Desktop.
HTML/JavaScript menu bar that can be navigated with the mouse and keyboard. Tested with the NVDA screen reader; using ARIA role="menubar" reduces noise and automatically enters focus mode. *** requires https://gist.github.com/wizard04wsu/3ec34604d7538303e9f0 ***
ul.menubar, ul.menubar ul {
list-style-type:none;
}
ul.menubar > li {
display:inline-block;
vertical-align:top;
border:1px solid red;
}
ul.menubar ul {
display:none;
border:1px solid red;
margin-left:2.5em;
padding-left:0;
}
ul.menubar .menuItemHasSubmenu {
cursor:default;
}
ul.menubar .submenu {
display:none;
}
ul.menubar .open > .submenu {
display:block;
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Nav Test</title>
<link rel="stylesheet" type="text/css" media="all" href="accessibleMenubar.css">
<script type="text/javascript" src="https://gist.githubusercontent.com/wizard04wsu/3ec34604d7538303e9f0/raw/75e9dcd1ce8950b36fc98390bc58a4b396bbdf13/classnames.js"></script>
<script type="text/javascript" src="accessibleMenubar.js"></script>
</head>
<body>
<nav>
<ul id="navMenu" class="menubar">
<li class="menuItem">
<a class="menuItemFocus" href="#">Home</a></li>
<li class="menuItem">
<span class="menuItemFocus menuItemHasSubmenu">Category 1</span>
<ul class="submenu">
<li class="menuItem">
<a class="menuItemFocus" href="#">Link 1</a></li>
<li class="menuItem">
<span class="menuItemFocus menuItemHasSubmenu">Sub-category 1</span>
<ul class="submenu">
<li class="menuItem">
<a class="menuItemFocus" href="#">Link 1</a></li>
<li class="menuItem">
<a class="menuItemFocus" href="#">Link 2</a></li>
<li>---</li>
<li class="menuItem">
<a class="menuItemFocus" href="#">Link 3</a></li>
</ul></li>
<li class="menuItem">
<a class="menuItemFocus" href="#">Link 2</a></li>
</ul></li>
<li class="menuItem">
<span class="menuItemFocus menuItemHasSubmenu">Category 2</span>
<ul class="submenu">
<li class="menuItem">
<a class="menuItemFocus" href="#">Link 1</a></li>
<li class="menuItem">
<a class="menuItemFocus" href="#">Link 2</a></li>
<li class="menuItem">
<a class="menuItemFocus" href="#">Link 3</a></li>
</ul></li>
</ul>
</nav>
<script type="text/javascript">
AccessibleMenu("navMenu");
</script>
</body>
</html>
function AccessibleMenu(menubarID, classes){
"use strict";
var menubar, noscriptElems, i, items,
keyNavMode,
//### keyboard key codes ###//
kSpace = 32,
kEnter = 13,
kLeft = 37,
kUp = 38,
kRight = 39,
kDown = 40,
kEsc = 27,
kTab = 9;
classes = classes || {};
classes = { // default classname element
menubar: classes.menubar || "menubar", //<ul>
menuItem: classes.menuItem || "menuItem", //<li>
focus: classes.focus || "menuItemFocus", //<a> or <span> (typically)
focusSubmenu: classes.focusSubmenu || "menuItemHasSubmenu", //<span> (typically; *not* <a>)
submenu: classes.submenu || "submenu", //<ul>
separator: classes.separator || "separator", //<li>
noscript: classes.noscript || "noscript", //<li> (top-level menu items only displayed when scripting is disabled)
open: classes.open || "open" //added to a menu item when its submenu is open
};
menubar = document.getElementById(menubarID);
if(!hasClass(menubar, classes.menubar)){
menubar = menubar.getElementsByClassName(classes.menubar)[0];
if(!menubar){
throw new Error("Top-level menu not found for #"+menubarID);
}
}
//remove menu items that should only be shown if scripting is disabled
noscriptElems = menubar.getElementsByClassName(classes.noscript);
for(i=0; i<noscriptElems.length; i++){
noscriptElems[i].parentNode.removeChild(noscriptElems[i]);
}
//###############################################//
//### handle menubar losing focus via a click ###//
menubar.addEventListener("click", function (evt){ evt["insideMenubar_"+menubarID] = true; }, false);
document.addEventListener("click", function (evt){ if(!evt["insideMenubar_"+menubarID]) closeAllSubmenus(); }, false);
//###############################################//
//### set up aria attributes & event handlers ###//
menubar.setAttribute("role", "menubar");
items = menubar.children;
for(i=0; i<items.length; i++){
if(!hasClass(items[i], classes.menuItem)){
items[i].setAttribute("role", "presentation");
items.splice(i--, 1);
}
}
for(i=0; i<items.length; i++){
setUpMenuItem(items[i], 0, items.length, i+1);
}
function setUpMenuItem(menuItem, level, setSize, posInSet){
var focus, submenu, items, i;
focus = menuItem.getElementsByClassName(classes.focus)[0];
if(!focus){
console.log("No focusable element for ", menuItem);
menuItem.setAttribute("role", "presentation");
return;
}
focus.setAttribute("role", "menuitem");
focus.setAttribute("aria-setsize", setSize);
focus.setAttribute("aria-posinset", posInSet);
focus.addEventListener("focus", function (evt){ closeOtherSubmenus(evt.target); }, false);
focus.addEventListener("keydown", keyNav, false);
menuItem.addEventListener("click", clickNav, false);
menuItem.addEventListener("mouseenter", hoverNav, false);
menuItem.addEventListener("mouseleave", hoverNav, false);
menuItem.addEventListener("mousemove", hoverNav, false); //in case both the mouse & keyboard are used for navigation
if(level){
focus.tabIndex = -1;
focus.setAttribute("aria-level", level);
//when focus leaves the item, make sure it is no longer in the tab order
focus.addEventListener("blur", function (evt){ evt.target.tabIndex = -1; }, false);
//when item is focused, make sure it is in the tab order (e.g., if it was clicked on with the mouse)
focus.addEventListener("focus", function (evt){ evt.target.tabIndex = 0; }, false);
}
else{
focus.tabIndex = 0;
}
level++;
if(hasClass(focus, classes.focusSubmenu)){
focus.setAttribute("aria-haspopup", true);
}
submenu = menuItem.getElementsByClassName(classes.submenu)[0];
if(submenu){
submenu.setAttribute("role", "menu");
submenu.setAttribute("aria-hidden", true);
items = Array.prototype.slice.call(submenu.children);
for(i=0; i<items.length; i++){
if(!hasClass(items[i], classes.menuItem)){
if(hasClass(items[i], classes.separator)){
items[i].setAttribute("role", "separator");
}
else{
items[i].setAttribute("role", "presentation");
}
items.splice(i--, 1);
}
}
for(i=0; i<items.length; i++){
setUpMenuItem(items[i], level, items.length, i+1);
}
}
}
//##############################//
//### mouse event handlers ###//
//##############################//
function hoverNav(evt){
var li, submenu, branchLi, focus;
li = evt.target;
while(!hasClass(li, classes.menuItem)){
li = li.parentNode;
}
if(this != li) return; //evt.target was an item in this item's submenu
focus = li.getElementsByClassName(classes.focus)[0];
//get the item's submenu, if applicable
submenu = li.getElementsByClassName(classes.submenu)[0];
//##############################//
//### handle mouseenter ###//
if(evt.type == "mouseenter" || evt.type == "mousemove"){
keyNavMode = false;
//set focus to the hovered item & close other submenus
setFocus(focus);
if(submenu){ //entered an item with a submenu
//close all submenus of the item entered
closeAllSubmenus(li);
//open its submenu
addClass(li, classes.open);
openSubmenu(submenu);
}
}
else if(evt.type == "mouseleave"){
if(!keyNavMode){ //if it wasn't a keyboard event that caused the mouse to move out of the submenu
//close all submenus of the item left
closeAllSubmenus(li);
if(!getParentItem(li)){ //top-level item
focus.blur();
}
}
}
}
function clickNav(evt){
var li, focus, submenu;
li = evt.target;
while(!hasClass(li, classes.menuItem)){
li = li.parentNode;
}
if(this != li) return; //evt.target was an item in this item's submenu
focus = li.getElementsByClassName(classes.focus)[0];
//get the item's submenu, if applicable
submenu = li.getElementsByClassName(classes.submenu)[0];
if(submenu){ //clicked on an item with a submenu
//set focus to the item
setFocus(focus);
openSubmenu(submenu);
}
else{ //clicked on a link
closeAllSubmenus(); //close all navigation submenus
}
}
//##############################//
//### keyboard event handler ###//
//##############################//
function keyNav(evt){
var li, submenu, parentLi;
if(this != evt.target) return;
keyNavMode = true;
//get the menu item
li = evt.target.parentNode;
while(!hasClass(li, classes.menuItem)){
if(li == menubar) return;
li = li.parentNode;
}
//get the item's submenu, if applicable
if(hasClass(evt.target, classes.focusSubmenu)){
submenu = li.getElementsByClassName(classes.submenu)[0];
}
//get the parent menu item, if applicable
parentLi = getParentItem(li);
//##############################//
//### handle key presses ###//
if(evt.which == kEnter || evt.which == kSpace){
if(submenu){ //item has a submenu
addClass(li, classes.open);
openSubmenu(submenu, true); //open the submenu and focus first item
evt.preventDefault();
}
}
else if(evt.which == kUp || evt.which == kDown) {
if(submenu && !parentLi){ //top-level item with a submenu
addClass(li, classes.open);
openSubmenu(submenu, true); //open the submenu and focus first item
}
else{ //lower-level item
if(evt.which == kUp){
previousItem(); //select previous item
}
else{
nextItem(); //select next item
}
}
evt.preventDefault();
}
else if(evt.which == kLeft || evt.which == kRight){
if(!parentLi){ //top-level item
if(evt.which == kLeft){
previousItem(); //select previous item
}
else{
nextItem(); //select next item
}
}
else{ //lower-level item
if(evt.which == kLeft){
closeMenu() //close this menu
}
else if(submenu){ //item has a submenu
addClass(li, classes.open);
openSubmenu(submenu, true); //open the submenu and focus first item
}
}
evt.preventDefault();
}
else if(evt.which == kEsc){
if(parentLi){ //lower-level item
closeMenu(); //close this menu
evt.preventDefault();
}
}
else if(evt.which == kTab){
closeAllSubmenus(); //close all navigation submenus
}
//##############################//
//### open/close menus ###//
function closeMenu(){
var menu = li.parentNode,
parentFocus;
//hide all submenus
closeAllSubmenus(li);
//hide the menu
removeClass(getParentItem(li), classes.open);
menu.setAttribute("aria-hidden", true);
//move focus to the parent item
parentFocus = parentLi.getElementsByClassName(classes.focus)[0];
setFocus(parentFocus);
}
//##############################//
//### item selection ###//
function nextItem(){
var sibling, siblingFocus;
//get next item
sibling = li;
while(sibling.nextElementSibling){
sibling = sibling.nextElementSibling;
if(sibling.getElementsByClassName(classes.focus)[0]){ //sibling is a menu item
siblingFocus = sibling.getElementsByClassName(classes.focus)[0];;
break;
}
}
if(!siblingFocus){ //no next item
//get first item
sibling = li.parentNode.firstElementChild;
while(sibling != li){
if(sibling.getElementsByClassName(classes.focus)[0]){ //sibling is a menu item
siblingFocus = sibling.getElementsByClassName(classes.focus)[0];
break;
}
sibling = sibling.nextElementSibling;
}
}
if(siblingFocus){
//move focus to the sibling item
setFocus(siblingFocus);
}
}
function previousItem(){
var sibling, siblingFocus;
//get previous item
sibling = li;
while(sibling.previousElementSibling){
sibling = sibling.previousElementSibling;
if(sibling.getElementsByClassName(classes.focus)[0]){ //sibling is a menu item
siblingFocus = sibling.getElementsByClassName(classes.focus)[0];
break;
}
}
if(!siblingFocus){ //no previous item
//get last item
sibling = li.parentNode.lastElementChild;
while(sibling != li){
if(sibling.getElementsByClassName(classes.focus)[0]){ //sibling is a menu item
siblingFocus = sibling.getElementsByClassName(classes.focus)[0];
break;
}
sibling = sibling.previousElementSibling;
}
}
if(siblingFocus){
//move focus to the sibling item
setFocus(siblingFocus);
}
}
}
//##############################//
//### helper functions ###//
//##############################//
function getParentItem(menuItem){
var parentLi = menuItem.parentNode;
while(parentLi != menubar){
if(hasClass(parentLi, classes.menuItem)){
return parentLi;
}
parentLi = parentLi.parentNode;
}
}
function setFocus(focusElem){
focusElem.tabIndex = 0; //must be done before .focus() to make sure the element gets the dotted outline (in IE, at least)
//focusElem.focus(); //for some inexplicable reason, if the Enter key triggers this code and `focusElem` is a link,
// this also fires a click event on the link
//the workaround:
setTimeout(function (){ focusElem.focus(); }, 0);
}
function openSubmenu(submenu, focusFirstItem){
var submenuFocus;
//display the submenu
submenu.setAttribute("aria-hidden", false);
if(focusFirstItem){
//move focus to first item in the submenu
submenuFocus = submenu.getElementsByClassName(classes.focus)[0];
setFocus(submenuFocus);
}
}
//close all submenus that are not part of the currently focused branch
function closeOtherSubmenus(focus){
var li, branchLi;
li = focus;
while(!hasClass(li, classes.menuItem)){
li = li.parentNode;
}
//close all submenus that are not part of this branch
branchLi = li;
while(branchLi){
closeSiblingSubmenus(branchLi);
branchLi = getParentItem(branchLi);
}
}
//recursively close all submenus of the item
function closeAllSubmenus(menuItem){
var menubarItems, submenu, submenuItems, i;
if(!menuItem){ //close all navigation submenus
//recursively close all submenus of the menubar
menubarItems = menubar.children;
for(i=0; i<menubarItems.length; i++){
closeAllSubmenus(menubarItems[i]);
}
}
else{
submenu = menuItem.getElementsByClassName(classes.submenu)[0];
if(!submenu){ //item does not have a submenu
return;
}
//recursively close all submenus of this item's submenu
submenuItems = submenu.children;
for(i=0; i<submenuItems.length; i++){
closeAllSubmenus(submenuItems[i]);
}
//hide this submenu
removeClass(menuItem, classes.open);
submenu.setAttribute("aria-hidden", true);
}
}
function closeSiblingSubmenus(menuItem){
var items = menuItem.parentNode.children,
i;
for(i=0; i<items.length; i++){
if(items[i] != menuItem){
closeAllSubmenus(items[i]);
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment