UNPKG

accessible-menu

Version:

A JavaScript library to help you generate WCAG accessible menus in the DOM.

412 lines (376 loc) 15.8 kB
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;