Instantly share code, notes, and snippets.
Last active
July 1, 2020 17:58
-
Star
(1)
1
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
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 ***
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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