Web Application Menus

Web application menus have a similar basis to navigation menus. They often consist of a horizontal menu bar and use fly-out functionality. There are some WAI-ARIA roles that are helping users with assistive technology to operate menus similar to desktop software menus. When using the menu WAI-ARIA attributes, the keyboard interaction should be similar to desktop software as well: the tab key is used to iterate through the top-level items only, the up and down arrows are used to navigate the sub menus. This keyboard behavior doesn’t come with the WAI-ARIA attributes, but needs to be added using scripting. A detailed explanation on the WAI-ARIA attributes and keyboard behavior can be found in the WAI-ARIA Authoring Practices document (draft).

In addition to the aria-expanded and aria-haspopup attributes, the following roles are used in the example:

  • aria-menubar: Represents a usually horizontal menu bar.
  • aria-menu: Represents a set of links or commands in a menu bar, it is used for the fly-out menus.
  • aria-menuitem: Represents an individual menu item.
Example:

The markup has no links, it is a bare nested list with some WAI-ARIA roles. As we will add keyboard and mouse interaction on our own, this is enough.

Code snippet:
<div role="menubar">
	<ul role="menu" aria-label="functions" id="appmenu">
		<li role="menuitem" aria-haspopup="true">
			File
			<ul role="menu">
				<li role="menuitem">New</li>
				<li role="menuitem">Open</li>
				<li role="menuitem">Print</li>
			</ul>
		</li></ul>
</div>

First, we collect all top-level menu items in a variable (appsMenuItems) as well as all submenu items (subMenuItems). An object is defined with the key codes of the keys that need to be handled. This makes the code much more readable. Two variables keep track of the focus in top-level items (currentIndex) and in submenus (subIndex).

Code snippet:
var appsMenuItems = document.querySelectorAll('#appmenu > li');
var subMenuItems = document.querySelectorAll('#appmenu > li li');
var keys = {
	tab:     9,
	enter:  13,
	esc:    27,
	space:  32,
	left:   37,
	up:     38,
	right:  39,
	down:   40
};
var currentIndex, subIndex;

To make the menu work for keyboard users, a tabindex attribute with the value -1 is added to the menu items. This enables scripts to set the focus on the element. The first menu item (“File” in this example) is assigned a tabindex value of 0 which adds it to the tab order and lets the user access the menu. The currentIndex variable is initialized as soon as this first item gets focus.

Code snippet:
Array.prototype.forEach.call(appsMenuItems, function(el, i){
		if (0 == i) {
			el.setAttribute('tabindex', '0');
			el.addEventListener("focus", function() {
				currentIndex = 0;
			});
		} else {
			el.setAttribute('tabindex', '-1');
		}
});

Array.prototype.forEach.call(subMenuItems, function(el, i){
	el.setAttribute('tabindex', '-1');
});

All top-level menu items close open submenus when they receive focus and reset the subIndex variable. When individual items are clicked, the visibility of the submenu is toggled by changing the aria-expanded value. If a key is pressed, the appropriate action is carried out.

Key mapping for top-level menu items
Key Action
tab ⇥ Select the next top-level menu item
right →
shift ⇧ + tab ⇥ Select the previous top-level menu item
left ←
return/enter ↵ Open the submenu, select first submenu item.
space
down ↓
up ↑ Open the submenu, select last submenu item.
esc Leave the menu
Code snippet:
Array.prototype.forEach.call(appsMenuItems, function(el, i){
	/* code above */

	el.addEventListener("focus", function() {
		subIndex = 0;
		Array.prototype.forEach.call(appsMenuItems, function(el, i){
			el.setAttribute('aria-expanded', "false");
		});
	});

	el.addEventListener("click",  function(event){
		if (this.getAttribute('aria-expanded') == 'false'
				|| this.getAttribute('aria-expanded') ==  null) {
			this.setAttribute('aria-expanded', "true");
		} else {
			this.setAttribute('aria-expanded', "false");
		}
		event.preventDefault();
		return false;
	});

	el.addEventListener("keydown", function(event) {
		switch (event.keyCode) {
			case keys.right:
				gotoIndex(currentIndex + 1);
				break;
			case keys.left:
				gotoIndex(currentIndex - 1);
				break;
			case keys.tab:
				if (event.shiftKey) {
					gotoIndex(currentIndex - 1);
				} else {
					gotoIndex(currentIndex + 1);
				}
				break;
			case keys.enter:
			case keys.space:
			case keys.down:
				this.click();
				subindex = 0;
				gotoSubIndex(this.querySelector('ul'), 0);
				break;
			case keys.up:
				this.click();
				var submenu = this.querySelector('ul');
				subindex = submenu.querySelectorAll('li').length - 1;
				gotoSubIndex(submenu, subindex);
				break;
			case keys.esc:
				document.querySelector('a[href="#related"]').focus();
		}
		event.preventDefault();
	});
});

Submenu items do behave differently when interacting with them on the keyboard, see the following table for details:

Key mapping for submenu items
Key Action
tab ⇥ Close the submenu, select the next top-level menu item
right →
shift ⇧ + tab ⇥ Close the submenu, select the previous top-level menu item
left ←
return/enter ↵ Carry out function of this item. (In this example: bring up a dialog box with the text of the chosen menu item.)
space
down ↓ Select next submenu item
up ↑ Select previous submenu item
esc Close the submenu, select the current top-level menu item
Code snippet:
Array.prototype.forEach.call(subMenuItems, function(el, i){
	el.setAttribute('tabindex', '-1');
	el.addEventListener("keydown", function(event) {
			switch (event.keyCode) {
				case keys.tab:
					if (event.shiftKey) {
						gotoIndex(currentIndex - 1);
					} else {
						gotoIndex(currentIndex + 1);
					}
					break;
				case keys.right:
					gotoIndex(currentIndex + 1);
					break;
				case keys.left:
					gotoIndex(currentIndex - 1);
					break;
				case keys.esc:
					gotoIndex(currentIndex);
					break;
				case keys.down:
					gotoSubIndex(this.parentNode, subIndex + 1);
					break;
				case keys.up:
					gotoSubIndex(this.parentNode, subIndex - 1);
					break;
				case keys.space:
				case keys.enter:
					alert(this.innerText);
					break;
			}
			event.preventDefault();
			event.stopPropagation();
			return false;
		});
	el.addEventListener("click", function(event) {
			alert(this.innerHTML);
			event.preventDefault();
			event.stopPropagation();
			return false;
		});
});

See the complete example code.