accessible-menu
Version:
A JavaScript library to help you generate WCAG accessible menus in the DOM.
412 lines (376 loc) • 15.8 kB
JavaScript
import BaseMenu from "./_baseMenu.js";
import DisclosureMenuItem from "./disclosureMenuItem.js";
import DisclosureMenuToggle from "./disclosureMenuToggle.js";
import { preventEvent, keyPress } from "./eventHandlers.js";
import { isValidType } from "./validate.js";
/**
* An accessible disclosure menu in the DOM.
*
* See Example Disclosure for Navigation Menus
*
* @extends BaseMenu
*/
class DisclosureMenu extends BaseMenu {
/**
* The class to use when generating submenus.
*
* @protected
*
* @type {typeof DisclosureMenu}
*/
_MenuType = DisclosureMenu;
/**
* The class to use when generating menu items.
*
* @protected
*
* @type {typeof DisclosureMenuItem}
*/
_MenuItemType = DisclosureMenuItem;
/**
* The class to use when generating submenu toggles.
*
* @protected
*
* @type {typeof DisclosureMenuToggle}
*/
_MenuToggleType = DisclosureMenuToggle;
/**
* The index of the currently selected menu item in the menu.
*
* @protected
*
* @type {number}
*/
_currentChild = -1;
/**
* A flag to add optional keyboard support (Arrow keys, "Home", and "End") to the menu.
*
* @protected
*
* @type {boolean}
*/
_optionalSupport = false;
/**
* Constructs a new `DisclosureMenu`.
*
* @param {object} options - The options for generating the menu.
* @param {HTMLElement} options.menuElement - The menu element in the DOM.
* @param {string} [options.menuItemSelector = li] - The query selector string for menu items.
* @param {string} [options.menuLinkSelector = a] - The query selector string for menu links.
* @param {string} [options.submenuItemSelector = li:has(ul)] - The query selector string for menu items containing submenus.
* @param {string} [options.submenuToggleSelector = button] - The query selector string for submenu toggle buttons/links.
* @param {string} [options.submenuSelector = ul] - The query selector string for submenus.
* @param {?HTMLElement} [options.controllerElement = null] - The element controlling the menu in the DOM.
* @param {?HTMLElement} [options.containerElement = null] - The element containing the menu in the DOM.
* @param {?(string|string[])} [options.openClass = show] - The class to apply when a menu is "open".
* @param {?(string|string[])} [options.closeClass = hide] - The class to apply when a menu is "closed".
* @param {?(string|string[])} [options.transitionClass = transitioning] - The class to apply when a menu is transitioning between "open" and "closed" states.
* @param {number} [options.transitionDuration = 250] - The duration of the transition between "open" and "closed" states (in milliseconds).
* @param {boolean} [options.openDuration = -1] - The duration of the transition from "closed" to "open" states (in milliseconds).
* @param {boolean} [options.closeDuration = -1] - The duration of the transition from "open" to "closed" states (in milliseconds).
* @param {boolean} [options.isTopLevel = true] - A flag to mark the root menu.
* @param {?DisclosureMenu} [options.parentMenu = null] - The parent menu to this menu.
* @param {string} [options.hoverType = off] - The type of hoverability a menu has.
* @param {number} [options.hoverDelay = 250] - The delay for opening and closing menus if the menu is hoverable (in milliseconds).
* @param {number} [options.enterDelay = -1] - The delay for opening a menu if the menu is focusable (in milliseconds).
* @param {number} [options.leaveDelay = -1] - The delay for closing a menu if the menu is focusable (in milliseconds).
* @param {boolean} [options.optionalKeySupport = false] - A flag to add optional keyboard support (Arrow keys, Home, and End) to the menu.
* @param {?string} [options.prefix = am-] - The prefix to use for CSS custom properties.
* @param {boolean} [options.initialize = true] - A flag to initialize the menu immediately upon creation.
*/
constructor({
menuElement,
menuItemSelector = "li",
menuLinkSelector = "a",
submenuItemSelector = "li:has(ul)",
submenuToggleSelector = "button",
submenuSelector = "ul",
controllerElement = null,
containerElement = null,
openClass = "show",
closeClass = "hide",
transitionClass = "transitioning",
transitionDuration = 250,
openDuration = -1,
closeDuration = -1,
isTopLevel = true,
parentMenu = null,
hoverType = "off",
hoverDelay = 250,
enterDelay = -1,
leaveDelay = -1,
optionalKeySupport = false,
prefix = "am-",
initialize = true,
}) {
super({
menuElement,
menuItemSelector,
menuLinkSelector,
submenuItemSelector,
submenuToggleSelector,
submenuSelector,
controllerElement,
containerElement,
openClass,
closeClass,
transitionClass,
transitionDuration,
openDuration,
closeDuration,
isTopLevel,
parentMenu,
hoverType,
hoverDelay,
enterDelay,
leaveDelay,
prefix,
});
// Set optional key support.
this._optionalSupport = optionalKeySupport;
if (initialize) {
this.initialize();
}
}
/**
* Initializes the menu.
*
* Initialize will call BaseMenu's initialize method
* as well as set up focus,
* click,
* hover,
* keydown, and
* keyup events for the menu.
*
* If the BaseMenu's initialize method throws an error,
* this will catch it and log it to the console.
*/
initialize() {
try {
super.initialize();
this._handleFocus();
this._handleClick();
this._handleHover();
this._handleKeydown();
this._handleKeyup();
} catch (error) {
console.error(error);
}
}
/**
* A flag to add optional keyboard support (Arrow keys, "Home", and "End") to the menu.
*
* This functions differently for root vs. submenus.
* Submenus will always inherit their root menu's optionalKeySupport.
*
* @type {boolean}
*
* @see _optionalSupport
*/
get optionalKeySupport() {
return this.isTopLevel
? this._optionalSupport
: this.elements.rootMenu.optionalKeySupport;
}
set optionalKeySupport(value) {
isValidType("boolean", { optionalKeySupport: value });
this._optionalSupport = value;
}
/**
* Validates all aspects of the menu to ensure proper functionality.
*
* @protected
*
* @return {boolean} - The result of the validation.
*/
_validate() {
let check = super._validate();
// Option key support check.
const optionalSupportCheck = isValidType("boolean", {
optionalKeySupport: this._optionalSupport,
});
if (!optionalSupportCheck.status) {
this._errors.push(optionalSupportCheck.error.message);
check = false;
}
return check;
}
/**
* Handles click events throughout the menu for proper use.
*
* - Adds all event listeners listed in
* BaseMenu's _handleClick method.
* - Adds a `pointerup` listener to the `document` so if the user
* clicks outside of the menu it will close if it is open.
*
* @protected
*/
_handleClick() {
super._handleClick();
// Close the menu if a click event happens outside of it.
document.addEventListener("pointerup", (event) => {
if (this.focusState !== "none") {
this.currentEvent = "mouse";
if (
!this.dom.menu.contains(event.target) &&
!this.dom.menu !== event.target
) {
this.closeChildren();
this.blur();
if (this.elements.controller) {
this.elements.controller.close();
}
this.elements.rootMenu.hasOpened = false;
}
}
});
}
/**
* Handles keydown events throughout the menu for proper menu use.
*
* This method exists to assist the _handleKeyup method.
* - Adds all `keydown` listeners from BaseMenu's _handleKeydown method
* - Adds a `keydown` listener to the menu/all submenus.
* - Blocks propagation on the following keys: "Space", "Enter", and "Escape".
* - _If_ optional keyboard support
* is enabled, blocks propagation on the following keys:
* "ArrowUp", "ArrowRight", "ArrowDown", "ArrowLeft", "Home", and "End".
*
* @protected
*/
_handleKeydown() {
super._handleKeydown();
this.dom.menu.addEventListener("keydown", (event) => {
this.currentEvent = "keyboard";
const key = keyPress(event);
// Prevent default event actions if we're handling the keyup event.
if (this.focusState === "self") {
const keys = ["Space", "Enter"];
const controllerKeys = ["Escape"];
const parentKeys = ["Escape"];
const optionalKeys = [
"ArrowUp",
"ArrowRight",
"ArrowDown",
"ArrowLeft",
"Home",
"End",
];
if (keys.includes(key)) {
preventEvent(event);
} else if (this.optionalKeySupport && optionalKeys.includes(key)) {
preventEvent(event);
} else if (this.elements.controller && controllerKeys.includes(key)) {
preventEvent(event);
} else if (this.elements.parentMenu && parentKeys.includes(key)) {
preventEvent(event);
}
}
});
}
/**
* Handles keyup events throughout the menu for proper menu use.
*
* Adds all `keyup` listeners from BaseMenu's _handleKeyup method.
*
* Adds the following keybindings (explanations are taken from the
* WAI ARIA Practices Example Disclosure for Navigation Menus):
*
* | Key | Function |
* | --- | --- |
* | _Tab_ or _Shift + Tab_ | Move keyboard focus among top-level buttons, and if a dropdown is open, into and through links in the dropdown. |
* | _Space_ or _Enter_ | <ul><li>If focus is on a disclosure button, activates the button, which toggles the visibility of the dropdown.</li><li>If focus is on a link:<ul><li>If any link has aria-current set, removes it.</li><li>Sets aria-current="page" on the focused link.</li><li>Activates the focused link.</li></ul></li></ul> |
* | _Escape_ | If a dropdown is open, closes it and sets focus on the button that controls that dropdown. |
* | _Down Arrow_ or _Right Arrow_ (Optional}) | <ul><li>If focus is on a button and its dropdown is collapsed, and it is not the last button, moves focus to the next button.</li><li>if focus is on a button and its dropdown is expanded, moves focus to the first link in the dropdown.</li><li>If focus is on a link, and it is not the last link, moves focus to the next link.</li></ul> |
* | _Up Arrow_ or _Left Arrow_ (Optional}) | <ul><li>If focus is on a button, and it is not the first button, moves focus to the previous button.</li><li>If focus is on a link, and it is not the first link, moves focus to the previous link.</li></ul> |
* | _Home_ (Optional) | <ul><li>If focus is on a button, and it is not the first button, moves focus to the first button.</li><li>If focus is on a link, and it is not the first link, moves focus to the first link.</li></ul> |
* | _End_ (Optional) | <ul><li>If focus is on a button, and it is not the last button, moves focus to the last button.</li><li>If focus is on a link, and it is not the last link, moves focus to the last link.</li></ul> |
*
* The optional keybindings are controlled by the menu's optionalKeySupport value.
*
* @protected
*/
_handleKeyup() {
super._handleKeyup();
this.dom.menu.addEventListener("keyup", (event) => {
this.currentEvent = "keyboard";
const key = keyPress(event);
if (this.focusState === "self") {
if (key === "Space" || key === "Enter") {
// Hitting Space or Enter:
// - If focus is on a disclosure button, activates the button, which toggles the visibility of the dropdown.
preventEvent(event);
if (this.currentMenuItem.isSubmenuItem) {
if (this.currentMenuItem.elements.toggle.isOpen) {
this.currentMenuItem.elements.toggle.close();
} else {
this.currentMenuItem.elements.toggle.preview();
}
} else {
this.currentMenuItem.dom.link.click();
}
} else if (key === "Escape") {
// Hitting Escape
// - If a dropdown is open, closes it.
// - If was within the closed dropdown, sets focus on the button that controls that dropdown.
const hasOpenChild = this.elements.submenuToggles.some(
(toggle) => toggle.isOpen
);
if (hasOpenChild) {
preventEvent(event);
this.closeChildren();
} else if (this.elements.parentMenu) {
preventEvent(event);
this.elements.parentMenu.currentEvent = this.currentEvent;
this.elements.parentMenu.closeChildren();
this.elements.parentMenu.focusCurrentChild();
} else if (
this.isTopLevel &&
this.elements.controller &&
this.elements.controller.isOpen
) {
this.elements.controller.close();
this.focusController();
}
} else if (this.optionalKeySupport) {
if (key === "ArrowDown" || key === "ArrowRight") {
// Hitting the Down or Right Arrow:
// - If focus is on a button and its dropdown is collapsed, and it is not the last button, moves focus to the next button.
// - If focus is on a button and its dropdown is expanded, moves focus to the first link in the dropdown.
// - If focus is on a link, and it is not the last link, moves focus to the next link.
preventEvent(event);
if (
this.currentMenuItem.isSubmenuItem &&
this.currentMenuItem.elements.toggle.isOpen
) {
this.currentMenuItem.elements.childMenu.currentEvent = "keyboard";
this.currentMenuItem.elements.childMenu.focusFirstChild();
} else {
this.focusNextChild();
}
} else if (key === "ArrowUp" || key === "ArrowLeft") {
// Hitting the Up or Left Arrow:
// - If focus is on a button, and it is not the first button, moves focus to the previous button.
// - If focus is on a link, and it is not the first link, moves focus to the previous link.
preventEvent(event);
this.focusPreviousChild();
} else if (key === "Home") {
// Hitting Home:
// - If focus is on a button, and it is not the first button, moves focus to the first button.
// - If focus is on a link, and it is not the first link, moves focus to the first link.
preventEvent(event);
this.focusFirstChild();
} else if (key === "End") {
// Hitting End:
// - If focus is on a button, and it is not the last button, moves focus to the last button.
// - If focus is on a link, and it is not the last link, moves focus to the last link.
preventEvent(event);
this.focusLastChild();
}
}
}
});
}
}
export default DisclosureMenu;