andy-h
10/30/2015 - 1:57 PM

HTML/JavaScript menu bar that can be navigated with the mouse and keyboard. Tested with the NVDA screen reader; using ARIA role="menubar" re

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 ***

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]);
			}
		}
		
	}
	
}
<!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>
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;
}