UNPKG

accessible-menu

Version:

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

1,811 lines (1,601 loc) 51.6 kB
// eslint-disable-next-line no-unused-vars /* global DisclosureMenu, Menubar, TopLinkDisclosureMenu, Treeview */ import BaseMenuToggle from "./_baseMenuToggle.js"; import BaseMenuItem from "./_baseMenuItem.js"; import { isValidInstance, isValidType, isQuerySelector, isValidClassList, isValidState, isValidEvent, isValidHoverType, isTag, } from "./validate.js"; import { preventEvent, keyPress } from "./eventHandlers.js"; /** * An accessible navigation element in the DOM. * * This is intended to be used as a "base" to other menus and not to be used on * it's own in the DOM. */ class BaseMenu { /** * The class to use when generating submenus. * * @protected * * @type {typeof BaseMenu} */ _MenuType = BaseMenu; /** * The class to use when generating menu items. * * @protected * * @type {typeof BaseMenuItem} */ _MenuItemType = BaseMenuItem; /** * The class to use when generating submenu toggles. * * @protected * * @type {typeof BaseMenuToggle} */ _MenuToggleType = BaseMenuToggle; /** * The DOM elements within the menu. * * @protected * * @type {Object<HTMLElement, HTMLElement[]>} * * @property {HTMLElement} menu - The menu element. * @property {HTMLElement[]} menuItems - An array of menu items. * @property {HTMLElement[]} submenuItems - An array of menu items that also contain submenu elements. * @property {HTMLElement[]} submenuToggles - An array of menu links that function as submenu toggles. * @property {HTMLElement[]} submenus - An array of submenu elements. * @property {HTMLElement} controller - The toggle for this menu. * @property {HTMLElement} container - The container for this menu. */ _dom = { menu: null, menuItems: [], submenuItems: [], submenuToggles: [], submenus: [], controller: null, container: null, }; /** * The query selectors used by the menu to populate the dom. * * @protected * * @type {Object<string>} * * @property {string} menuItems - The query selector for menu items. * @property {string} menuLinks - The query selector for menu links. * @property {string} submenuItems - The query selector for menu items containing submenus. * @property {string} submenuToggles - The query selector for menu links that function as submenu toggles. * @property {string} submenus - The query selector for for submenus. */ _selectors = { menuItems: "", menuLinks: "", submenuItems: "", submenuToggles: "", submenus: "", }; /** * The declared accessible-menu elements within the menu. * * @protected * * @type {Object<BaseMenu, BaseMenuToggle, BaseMenuItem[], BaseMenuToggle[]>} * * @property {BaseMenuItem[]} menuItems - An array of menu items. * @property {BaseMenuToggle[]} submenuToggles - An array of menu toggles. * @property {?BaseMenuToggle} controller - A menu toggle that controls this menu. * @property {?BaseMenu} parentMenu - The parent menu. * @property {?BaseMenu} rootMenu - The root menu of the menu tree. */ _elements = { menuItems: [], submenuToggles: [], controller: null, parentMenu: null, rootMenu: null, }; /** * The class(es) to apply when the menu is open. * * @protected * * @type {string|string[]} */ _openClass = "show"; /** * The class(es) to apply when the menu is closed. * * @protected * * @type {string|string[]} */ _closeClass = "hide"; /** * The class(es) to apply when the menu is transitioning between states. * * @protected * * @type {string|string[]} */ _transitionClass = "transitioning"; /** * The duration time (in milliseconds) for the transition between open and closed states. * * @protected * * @type {number} */ _transitionDuration = 250; /** * The duration time (in milliseconds) for the transition from closed to open states. * * @protected * * @type {number} */ _openDuration = -1; /** * The duration time (in milliseconds) for the transition from open to closed states. * * @protected * * @type {number} */ _closeDuration = -1; /** * A flag marking the root menu. * * @protected * * @type {boolean} */ _root = true; /** * The index of the currently selected menu item in the menu. * * @protected * * @type {number} */ _currentChild = 0; /** * The current state of the menu's focus. * * @protected * * @type {string} */ _focusState = "none"; /** * This last event triggered on the menu. * * @protected * * @type {string} */ _currentEvent = "none"; /** * The type of hoverability for the menu. * * @protected * * @type {string} */ _hoverType = "off"; /** * The delay time (in milliseconds) used for pointerenter/pointerleave events to take place. * * @protected * * @type {number} */ _hoverDelay = 250; /** * The delay time (in milliseconds) used for pointerenter events to take place. * * @protected * * @type {number} */ _enterDelay = -1; /** * The delay time (in milliseconds) used for pointerleave events to take place. * * @protected * * @type {number} */ _leaveDelay = -1; /** * The prefix to use for CSS custom properties. * * @protected * * @type {string} */ _prefix = "am-"; /** * A variable to hold the hover timeout function. * * @protected * * @type {?Function} */ _hoverTimeout = null; /** * A flag to check if the menu can dynamically hover based on if a menu has been opened already. * * @protected * * @type {boolean} */ _hasOpened = false; /** * An array of error messages generated by the menu. * * @protected * * @type {string[]} */ _errors = []; /** * Constructs a new `BaseMenu`. * * @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 = a] - 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 = false] - A flag to mark the root menu. * @param {?BaseMenu} [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 menus if the menu is hoverable (in milliseconds). * @param {number} [options.leaveDelay = -1] - The delay for closing menus if the menu is hoverable (in milliseconds). * @param {?string} [options.prefix = am-] - The prefix to use for CSS custom properties. */ constructor({ menuElement, menuItemSelector = "li", menuLinkSelector = "a", submenuItemSelector = "li:has(ul)", submenuToggleSelector = "a", 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, prefix = "am-", }) { // Set DOM elements. this._dom.menu = menuElement; this._dom.controller = controllerElement; this._dom.container = containerElement; // Set DOM selectors. this._selectors.menuItems = menuItemSelector; this._selectors.menuLinks = menuLinkSelector; this._selectors.submenuItems = submenuItemSelector; this._selectors.submenuToggles = submenuToggleSelector; this._selectors.submenus = submenuSelector; // Set menu elements. this._elements.menuItems = []; this._elements.submenuToggles = []; this._elements.controller = null; this._elements.parentMenu = parentMenu; this._elements.rootMenu = isTopLevel ? this : null; // Set open/close classes. this._openClass = openClass || ""; this._closeClass = closeClass || ""; this._transitionClass = transitionClass || ""; // Set transition duration. this._transitionDuration = transitionDuration; this._openDuration = openDuration; this._closeDuration = closeDuration; // Set prefix. this._prefix = prefix || ""; // Set root. this._root = isTopLevel; // Set hover settings. this._hoverType = hoverType; this._hoverDelay = hoverDelay; this._enterDelay = enterDelay; this._leaveDelay = leaveDelay; } /** * Initializes the menu. * * The following steps will be taken to initialize the menu: * - Validate that the menu can initialize. * - Find the root menu of the menu tree if it isn't already set. * - Populate all DOM elements within the dom. * - If the current menu is the root menu _and_ has a controller, initialize * the controller. * - Populate the menu elements within the elements. * - Set the transition duration custom prop for the menu. * * @public * * @throws {Error} Will throw an Error if validate returns `false`. */ initialize() { if (!this._validate()) { throw new Error( `AccessibleMenu: cannot initialize menu. The following errors have been found:\n - ${this.errors.join( "\n - " )}` ); } // Get the root menu if it doesn't exist. if (this.elements.rootMenu === null) this._findRootMenu(this); // Set all of the DOM elements. this._setDOMElements(); if (this.isTopLevel) { if (this.dom.controller && this.dom.container) { // Create a new BaseMenuToggle to control the menu. const toggle = new this._MenuToggleType({ menuToggleElement: this.dom.controller, parentElement: this.dom.container, controlledMenu: this, }); // If the toggle isn't a button, add the appropriate role to let // screen readers know it should act like a button. if (!isTag("button", { toggle: toggle.dom.toggle })) { toggle.dom.toggle.setAttribute("role", "button"); } // Set the controller's aria attributes. // These aren't necessarily the same as the standard menu toggle. toggle.dom.toggle.setAttribute("aria-controls", this.dom.menu.id); this._elements.controller = toggle; } } this._createChildElements(); this._setTransitionDurations(); // Add the menu to a globally accessible list of menus. if (this.isTopLevel) { window.AccessibleMenu = window.AccessibleMenu || { menus: {}, }; window.AccessibleMenu.menus[this.dom.menu.id] = this; } } /** * The DOM elements within the menu. * * @readonly * * @type {Object<HTMLElement, HTMLElement[]>} * * @see _dom */ get dom() { return this._dom; } /** * The query selectors used by the menu to populate the dom. * * @readonly * * @type {Object<string>} * * @see _selectors */ get selectors() { return this._selectors; } /** * The declared accessible-menu elements within the menu. * * @readonly * * @type {Object<BaseMenu, BaseMenuToggle, BaseMenuItem[], BaseMenuToggle[]>} * * @see _elements */ get elements() { return this._elements; } /** * The flag marking the root menu. * * @readonly * * @type {boolean} * * @see _root */ get isTopLevel() { return this._root; } /** * The class(es) to apply when the menu is open. * * This functions differently for root vs. submenus. * Submenus will always inherit their root menu's open class(es). * * @type {string|string[]} * * @see _openClass */ get openClass() { return this.isTopLevel ? this._openClass : this.elements.rootMenu.openClass; } /** * The class(es) to apply when the menu is closed. * * This functions differently for root vs. submenus. * Submenus will always inherit their root menu's close class(es). * * @type {string|string[]} * * @see _closeClass */ get closeClass() { return this.isTopLevel ? this._closeClass : this.elements.rootMenu.closeClass; } /** * The class(es) to apply when the menu is transitioning between open and closed. * * This functions differently for root vs. submenus. * Submenus will always inherit their root menu's transition class(es). * * @type {string|string[]} * * @see _transitionClass */ get transitionClass() { return this.isTopLevel ? this._transitionClass : this.elements.rootMenu.transitionClass; } /** * The duration time (in milliseconds) for the transition between open and closed states. * * This functions differently for root vs. submenus. * Submenus will always inherit their root menu's transition duration. * * Setting this value will also set the --am-transition-duration CSS custom property on the menu. * * @type {number} * * @see _transitionDuration */ get transitionDuration() { return this.isTopLevel ? this._transitionDuration : this.elements.rootMenu.transitionDuration; } /** * The duration time (in milliseconds) for the transition from closed to open states. * * This functions differently for root vs. submenus. * Submenus will always inherit their root menu's openDuration. * * If openDuration is set to -1, the transitionDuration value will be used instead. * * Setting this value will also set the --am-open-transition-duration CSS custom property on the menu. * * @type {number} * * @see _openDuration */ get openDuration() { if (this._openDuration === -1) return this.transitionDuration; return this.isTopLevel ? this._openDuration : this.elements.rootMenu.openDuration; } /** * The duration time (in milliseconds) for the transition from open to closed states. * * This functions differently for root vs. submenus. * Submenus will always inherit their root menu's closeDuration. * * If closeDuration is set to -1, the transitionDuration value will be used instead. * * Setting this value will also set the --am-close-transition-duration CSS custom property on the menu. * * @type {number} * * @see _closeDuration */ get closeDuration() { if (this._closeDuration === -1) return this.transitionDuration; return this.isTopLevel ? this._closeDuration : this.elements.rootMenu.closeDuration; } /** * The index of the currently selected menu item in the menu. * * - Attempting to set a value less than -1 will set the current child to -1. * - Attempting to set a value greater than or equal to the number of menu items * will set the current child to the index of the last menu item in the menu. * * If the current menu has a parent menu _and_ the menu's * current event is "mouse", The parent menu * will have it's current child updated as well to help with transitioning * between mouse and keyboard navigation. * * @type {number} * * @see _currentChild */ get currentChild() { return this._currentChild; } /** * The current state of the menu's focus. * * - If the menu has submenus, setting the focus state to "none" or "self" will * update all child menus to have the focus state of "none". * - If the menu has a parent menu, setting the focus state to "self" or "child" * will update all parent menus to have the focus state of "child". * * @type {string} * * @see _focusState */ get focusState() { return this._focusState; } /** * The last event triggered on the menu. * * @type {string} * * @see _currentEvent */ get currentEvent() { return this._currentEvent; } /** * The currently selected menu item. * * @readonly * * @type {BaseMenuItem} */ get currentMenuItem() { return this.elements.menuItems[this.currentChild]; } /** * The type of hoverability for the menu. * * This functions differently for root vs. submenus. * Submenus will always inherit their root menu's hoverability. * * @type {string} * * @see _hoverType */ get hoverType() { return this._root ? this._hoverType : this.elements.rootMenu.hoverType; } /** * The delay time (in milliseconds) used for pointerenter/pointerleave events to take place. * * This functions differently for root vs. submenus. * Submenus will always inherit their root menu's hover delay. * * @type {number} * * @see _hoverDelay */ get hoverDelay() { return this._root ? this._hoverDelay : this.elements.rootMenu.hoverDelay; } /** * The delay time (in milliseconds) used for pointerenter events to take place. * * This functions differently for root vs. submenus. * Submenus will always inherit their root menu's enter delay. * * If enterDelay is set to -1, the hoverDelay value will be used instead. * * @type {number} * * @see _enterDelay */ get enterDelay() { if (this._enterDelay === -1) return this.hoverDelay; return this._root ? this._enterDelay : this.elements.rootMenu.enterDelay; } /** * The delay time (in milliseconds) used for pointerleave events to take place. * * This functions differently for root vs. submenus. * Submenus will always inherit their root menu's leave delay. * * If leaveDelay is set to -1, the hoverDelay value will be used instead. * * @type {number} * * @see _leaveDelay */ get leaveDelay() { if (this._leaveDelay === -1) return this.hoverDelay; return this._root ? this._leaveDelay : this.elements.rootMenu.leaveDelay; } /** * The prefix to use for CSS custom properties. * * This functions differently for root vs. submenus. * Submenus will always inherit their root menu's prefix. * * @type {string} * * @see _prefix */ get prefix() { return this._root ? this._prefix : this.elements.rootMenu.prefix; } /** * A flag to check if the menu's focus methods should _actually_ move the focus in the DOM. * * This will be `false` unless any of the following criteria are met: * - The menu's current event is "keyboard". * - The menu's current event is "character". * - The menu's current event is "mouse" _and_ the menu's * hover type is "dynamic". * * @readonly * * @type {boolean} */ get shouldFocus() { let check = false; if (this.currentEvent === "keyboard" || this.currentEvent === "character") { check = true; } if (this.currentEvent === "mouse" && this.hoverType === "dynamic") { check = true; } return check; } /** * A flag to check if the menu can dynamically hover. * * This functions differently for root vs. submenus. * Submenus will always inherit their root menu's hasOpened. * * @type {boolean} * * @see _hasOpened */ get hasOpened() { return this._root ? this._hasOpened : this.elements.rootMenu.hasOpened; } /** * An array of error messages generated by the menu. * * @readonly * * @type {string[]} * * @see _errors */ get errors() { return this._errors; } set openClass(value) { isValidClassList({ openClass: value }); if (this._openClass !== value) { this._openClass = value; } } set closeClass(value) { isValidClassList({ closeClass: value }); if (this._closeClass !== value) { this._closeClass = value; } } set transitionClass(value) { isValidClassList({ transitionClass: value }); if (this._transitionClass !== value) { this._transitionClass = value; } } set transitionDuration(value) { isValidType("number", { value }); if (this._transitionDuration !== value) { this._transitionDuration = value; this._setTransitionDurations(); } } set openDuration(value) { isValidType("number", { value }); if (this._openDuration !== value) { this._openDuration = value; this._setTransitionDurations(); } } set closeDuration(value) { isValidType("number", { value }); if (this._closeDuration !== value) { this._closeDuration = value; this._setTransitionDurations(); } } set currentChild(value) { isValidType("number", { value }); /** * Update the parent menu's current child to make sure clicks * and other jumps don't interfere with keyboard navigation. * * @param {BaseMenu} menu - The initial menu. */ function setParentChild(menu) { const updateEvents = ["mouse", "character"]; if ( updateEvents.includes(menu.currentEvent) && menu.elements.parentMenu ) { let index = 0; let found = false; while ( !found && index < menu.elements.parentMenu.elements.menuItems.length ) { const menuItem = menu.elements.parentMenu.elements.menuItems[index]; if ( menuItem.isSubmenuItem && menuItem.elements.toggle.elements.controlledMenu === menu ) { found = true; menu.elements.parentMenu.currentEvent = menu.currentEvent; menu.elements.parentMenu.currentChild = index; } index++; } } } if (value < -1) { this._currentChild = -1; setParentChild(this); } else if (value >= this.elements.menuItems.length) { this._currentChild = this.elements.menuItems.length - 1; setParentChild(this); } else if (this.focusChild !== value) { this._currentChild = value; setParentChild(this); } } set focusState(value) { isValidState({ value }); if (this._focusState !== value) { this._focusState = value; } if ( this.elements.submenuToggles.length > 0 && (value === "self" || value === "none") ) { this.elements.submenuToggles.forEach((toggle) => { toggle.elements.controlledMenu.focusState = "none"; }); } if (this.elements.parentMenu && (value === "self" || value === "child")) { this.elements.parentMenu.focusState = "child"; } } set currentEvent(value) { isValidEvent({ value }); if (this._currentEvent !== value) { this._currentEvent = value; if (this.elements.submenuToggles.length > 0) { this.elements.submenuToggles.forEach((submenuToggle) => { submenuToggle.elements.controlledMenu.currentEvent = value; }); } } } set hoverType(value) { isValidHoverType({ value }); if (this._hoverType !== value) { this._hoverType = value; } } set hoverDelay(value) { isValidType("number", { value }); if (this._hoverDelay !== value) { this._hoverDelay = value; } } set enterDelay(value) { isValidType("number", { value }); if (this._enterDelay !== value) { this._enterDelay = value; } } set leaveDelay(value) { isValidType("number", { value }); if (this._leaveDelay !== value) { this._leaveDelay = value; } } set prefix(value) { isValidType("string", { value }); if (this._prefix !== value) { this._prefix = value; } } set hasOpened(value) { isValidType("boolean", { value }); if (this._hasOpened !== value) { this._hasOpened = value; } } /** * Validates all aspects of the menu to ensure proper functionality. * * @protected * * @return {boolean} - The result of the validation. */ _validate() { let check = true; // HTML element checks. let htmlElementChecks; if (this._dom.container !== null || this._dom.controller !== null) { htmlElementChecks = isValidInstance(HTMLElement, { menuElement: this._dom.menu, controllerElement: this._dom.controller, containerElement: this._dom.container, }); } else { htmlElementChecks = isValidInstance(HTMLElement, { menuElement: this._dom.menu, }); } if (!htmlElementChecks.status) { this._errors.push(htmlElementChecks.error.message); check = false; } // Query selector checks. let querySelectorChecks; if (this._selectors.submenuItems !== "") { querySelectorChecks = isQuerySelector({ menuItemSelector: this._selectors.menuItems, menuLinkSelector: this._selectors.menuLinks, submenuItemSelector: this._selectors.submenuItems, submenuToggleSelector: this._selectors.submenuToggles, submenuSelector: this._selectors.submenus, }); } else { querySelectorChecks = isQuerySelector({ menuItemSelector: this._selectors.menuItems, menuLinkSelector: this._selectors.menuLinks, }); } if (!querySelectorChecks.status) { this._errors.push(querySelectorChecks.error.message); check = false; } // Class list checks. if (this._openClass !== "") { const openClassCheck = isValidClassList({ openClass: this._openClass }); if (!openClassCheck.status) { this._errors.push(openClassCheck.error.message); check = false; } } if (this._closeClass !== "") { const closeClassCheck = isValidClassList({ closeClass: this._closeClass, }); if (!closeClassCheck.status) { this._errors.push(closeClassCheck.error.message); check = false; } } if (this._transitionClass !== "") { const transitionClassCheck = isValidClassList({ transitionClass: this._transitionClass, }); if (!transitionClassCheck.status) { this._errors.push(transitionClassCheck.error.message); check = false; } } // Transition duration check. const transitionDurationCheck = isValidType("number", { transitionDuration: this._transitionDuration, }); if (!transitionDurationCheck.status) { this._errors.push(transitionDurationCheck.error.message); check = false; } // Open duration check. const openDurationCheck = isValidType("number", { openDuration: this._openDuration, }); if (!openDurationCheck.status) { this._errors.push(openDurationCheck.error.message); check = false; } // Close duration check. const closeDurationCheck = isValidType("number", { closeDuration: this._closeDuration, }); if (!closeDurationCheck.status) { this._errors.push(closeDurationCheck.error.message); check = false; } // Top level check. const topLevelCheck = isValidType("boolean", { isTopLevel: this._root }); if (!topLevelCheck.status) { this._errors.push(topLevelCheck.error.message); check = false; } // Parent menu check. if (this._elements.parentMenu !== null) { const parentCheck = isValidInstance(BaseMenu, { parentMenu: this._elements.parentMenu, }); if (!parentCheck.status) { this._errors.push(parentCheck.error.message); check = false; } } // Hover type check. const hoverTypeCheck = isValidHoverType({ hoverType: this._hoverType }); if (!hoverTypeCheck.status) { this._errors.push(hoverTypeCheck.error.message); check = false; } // Hover delay check. const hoverDelayCheck = isValidType("number", { hoverDelay: this._hoverDelay, }); if (!hoverDelayCheck.status) { this._errors.push(hoverDelayCheck.error.message); check = false; } // Enter delay check. const enterDelayCheck = isValidType("number", { enterDelay: this._enterDelay, }); if (!enterDelayCheck.status) { this._errors.push(enterDelayCheck.error.message); check = false; } // Leave delay check. const leaveDelayCheck = isValidType("number", { leaveDelay: this._leaveDelay, }); if (!leaveDelayCheck.status) { this._errors.push(leaveDelayCheck.error.message); check = false; } // Prefix check. const prefixCheck = isValidType("string", { prefix: this._prefix }); if (!prefixCheck.status) { this._errors.push(prefixCheck.error.message); check = false; } return check; } /** * Sets DOM elements within the menu. * * Elements that are not stored inside an array cannot be set through this method. * * @protected * * @param {string} elementType - The type of element to populate. * @param {HTMLElement} [base = this.dom.menu] - The element used as the base for the querySelect. * @param {boolean} [overwrite = true] - A flag to set if the existing elements will be overwritten. */ _setDOMElementType(elementType, base = this.dom.menu, overwrite = true) { if (typeof this.selectors[elementType] === "string") { if (!Array.isArray(this.dom[elementType])) { throw new Error( `AccessibleMenu: The "${elementType}" element cannot be set through _setDOMElementType.` ); } if (base !== this.dom.menu) isValidInstance(HTMLElement, { base }); // Get the all elements matching the selector in the base. const domElements = Array.from( base.querySelectorAll(this.selectors[elementType]) ); // Filter the elements so only direct children of the base are kept. const filteredElements = domElements.filter( (item) => item.parentElement === base ); if (overwrite) { this._dom[elementType] = filteredElements; } else { this._dom[elementType] = [ ...this._dom[elementType], ...filteredElements, ]; } } else { throw new Error( `AccessibleMenu: "${elementType}" is not a valid element type within the menu.` ); } } /** * Resets DOM elements within the menu. * * Elements that are not stored inside an array cannot be reset through this method. * * @protected * * @param {string} elementType - The type of element to clear. */ _resetDOMElementType(elementType) { if (typeof this.dom[elementType] !== "undefined") { if (!Array.isArray(this.dom[elementType])) { throw new Error( `AccessibleMenu: The "${elementType}" element cannot be reset through _resetDOMElementType.` ); } this._dom[elementType] = []; } else { throw new Error( `AccessibleMenu: "${elementType}" is not a valid element type within the menu.` ); } } /** * Sets all DOM elements within the menu. * * Utilizes _setDOMElementType and * _resetDOMElementType. * * @protected */ _setDOMElements() { this._setDOMElementType("menuItems"); if (this.selectors.submenuItems !== "") { this._setDOMElementType("submenuItems"); this._resetDOMElementType("submenuToggles"); this._resetDOMElementType("submenus"); this.dom.submenuItems.forEach((item) => { this._setDOMElementType("submenuToggles", item, false); this._setDOMElementType("submenus", item, false); }); } } /** * Finds the root menu element. * * @protected * * @param {BaseMenu} menu - The menu to check. */ _findRootMenu(menu) { if (menu.isTopLevel) { this._elements.rootMenu = menu; } else if (menu.elements.parentMenu !== null) { this._findRootMenu(menu.elements.parentMenu); } else { throw new Error("Cannot find root menu."); } } /** * Creates and initializes all menu items and submenus. * * @protected */ _createChildElements() { this.dom.menuItems.forEach((element) => { let menuItem; if (this.dom.submenuItems.includes(element)) { // The menu's toggle controller DOM element. const toggler = element.querySelector(this.selectors.submenuToggles); // The actual menu DOM element. const submenu = element.querySelector(this.selectors.submenus); // Create the new menu and initialize it. const menu = new this._MenuType({ menuElement: submenu, menuItemSelector: this.selectors.menuItems, menuLinkSelector: this.selectors.menuLinks, submenuItemSelector: this.selectors.submenuItems, submenuToggleSelector: this.selectors.submenuToggles, submenuSelector: this.selectors.submenus, openClass: this.openClass, closeClass: this.closeClass, transitionClass: this.transitionClass, transitionDuration: this.transitionDuration, openDuration: this.openDuration, closeDuration: this.closeDuration, isTopLevel: false, parentMenu: this, hoverType: this.hoverType, hoverDelay: this.hoverDelay, enterDelay: this.enterDelay, leaveDelay: this.leaveDelay, }); // Create the new menu toggle. const toggle = new this._MenuToggleType({ menuToggleElement: toggler, parentElement: element, controlledMenu: menu, parentMenu: this, }); // Add the toggle to the list of toggles. this._elements.submenuToggles.push(toggle); // Create a new menu item. menuItem = new this._MenuItemType({ menuItemElement: element, menuLinkElement: toggler, parentMenu: this, isSubmenuItem: true, childMenu: menu, toggle, }); } else { const link = element.querySelector(this.selectors.menuLinks); // Create a new menu item. menuItem = new this._MenuItemType({ menuItemElement: element, menuLinkElement: link, parentMenu: this, }); } this._elements.menuItems.push(menuItem); }); } /** * Clears the hover timeout. * * @protected */ _clearTimeout() { clearTimeout(this._hoverTimeout); } /** * Sets the hover timeout. * * @protected * * @param {Function} callback - The callback function to execute. * @param {number} delay - The delay time in milliseconds. */ _setTimeout(callback, delay) { isValidType("function", { callback }); isValidType("number", { delay }); this._hoverTimeout = setTimeout(callback, delay); } /** * Handles focus events throughout the menu for proper menu use. * * - Adds a `focus` listener to every menu item so when it gains focus, * it will set the item's containing menu's focus state * to "self". * - Adds a `focusout` listener to the menu so when the menu loses focus, * it will close. * * @protected */ _handleFocus() { this.elements.menuItems.forEach((menuItem, index) => { menuItem.dom.link.addEventListener("focus", () => { this.focusState = "self"; this.currentChild = index; }); }); this.dom.menu.addEventListener("focusout", (event) => { if ( this.currentEvent !== "keyboard" || event.relatedTarget === null || this.dom.menu.contains(event.relatedTarget) ) { return; } this.focusState = "none"; this.closeChildren(); }); } /** * Handles click events throughout the menu for proper use. * * - Adds a `pointerdown` listener to every menu item that will blur * all menu items in the entire menu structure (starting at the root menu) and * then properly focus the clicked item. * - Adds a `pointerup` listener to every submenu item that will properly * toggle the submenu open/closed. * - Adds a `pointerup` listener to the menu's controller * (if the menu is the root menu) so when it is clicked it will properly * toggle open/closed. * * @protected */ _handleClick() { /** * Toggles a toggle element. * * @param {BaseMenu} menu - This menu. * @param {BaseMenuToggle} toggle - The menu toggle * @param {Event} event - A Javascript event. */ function toggleToggle(menu, toggle, event) { preventEvent(event); if (event.button !== 0) { return; } toggle.toggle(); if (toggle.isOpen) { menu.focusState = "self"; toggle.elements.controlledMenu.focusState = "none"; } } this.elements.menuItems.forEach((item, index) => { // Properly focus the current menu item. item.dom.link.addEventListener( "pointerdown", () => { this.currentEvent = "mouse"; this.elements.rootMenu.blurChildren(); this._clearTimeout(); this.focusChild(index); }, { passive: true } ); // Properly toggle submenus open and closed. if (item.isSubmenuItem) { item.elements.toggle.dom.toggle.addEventListener( "pointerup", (event) => { this.currentEvent = "mouse"; toggleToggle(this, item.elements.toggle, event); } ); } }); // Open the this menu if it's controller is clicked. if (this.isTopLevel && this.elements.controller) { this.elements.controller.dom.toggle.addEventListener( "pointerup", (event) => { this.currentEvent = "mouse"; toggleToggle(this, this.elements.controller, event); } ); } // If the menu has no open children, set hasOpened to false. document.addEventListener("pointerup", (event) => { if (this.focusState !== "none") { this.currentEvent = "mouse"; if ( !this.dom.menu.contains(event.target) && !this.dom.menu !== event.target ) { this.elements.rootMenu.hasOpened = this.elements.submenuToggles.some( (toggle) => toggle.isOpen ); } } }); } /** * Handles hover events throughout the menu for proper use. * * Adds `pointerenter` listeners to all menu items and `pointerleave` listeners * to all submenu items which function differently depending on * the menu's hover type. * * Before executing anything, the event is checked to make sure the event wasn't * triggered by a pen or touch. * * <strong>Hover Type "on"</strong> * - When a `pointerenter` event triggers on any menu item the menu's * current child value will change to that * menu item. * - When a `pointerenter` event triggers on a submenu item the * preview method for the submenu item's * toggle will be called. * - When a `pointerleave` event triggers on an open submenu item the * close method for the submenu item's toggle * will be called after a delay set by the menu's hover delay. * * <strong>Hover Type "dynamic"</strong> * - When a `pointerenter` event triggers on any menu item the menu's * current child value will change to that menu item. * - When a `pointerenter` event triggers on any menu item, and the menu's * focus state is not "none", the menu item * will be focused. * - When a `pointerenter` event triggers on a submenu item, and a submenu is * already open, the preview method for the submenu item's toggle will be called. * - When a `pointerenter` event triggers on a non-submenu item, and a submenu * is already open, the closeChildren method for the menu will be called. * - When a `pointerenter` event triggers on a submenu item, and no submenu is * open, no submenu-specific methods will be called. * - When a `pointerleave` event triggers on an open submenu item that is not a * root-level submenu item the close method for the submenu item's toggle * will be called and the submenu item will be focused after a delay set by * the menu's hover delay. * - When a `pointerleave` event triggers on an open submenu item that is a * root-level submenu item no submenu-specific methods will be called. * * <strong>Hover Type "off"</strong> * All `pointerenter` and `pointerleave` events are ignored. * * @protected */ _handleHover() { this.elements.menuItems.forEach((menuItem, index) => { menuItem.dom.link.addEventListener("pointerenter", (event) => { // Exit out of the event if it was not made by a mouse. if (event.pointerType === "pen" || event.pointerType === "touch") { return; } if (this.hoverType === "on") { this.currentEvent = "mouse"; this.elements.rootMenu.blurChildren(); this.focusChild(index); if (menuItem.isSubmenuItem) { if (this.enterDelay > 0) { this._clearTimeout(); this._setTimeout(() => { menuItem.elements.toggle.preview(); }, this.enterDelay); } else { menuItem.elements.toggle.preview(); } } } else if (this.hoverType === "dynamic") { this.currentChild = index; if (!this.isTopLevel || this.focusState !== "none") { this.currentEvent = "mouse"; this.elements.rootMenu.blurChildren(); this.focusCurrentChild(); } if (!this.isTopLevel || this.hasOpened) { this.currentEvent = "mouse"; this.elements.rootMenu.blurChildren(); this.focusCurrentChild(); if (menuItem.isSubmenuItem) { if (this.enterDelay > 0) { this._clearTimeout(); this._setTimeout(() => { menuItem.elements.toggle.preview(); }, this.enterDelay); } else { menuItem.elements.toggle.preview(); } } else { if (this.enterDelay > 0) { this._clearTimeout(); this._setTimeout(() => { this.closeChildren(); }, this.enterDelay); } else { this.closeChildren(); } } } } }); if (menuItem.isSubmenuItem) { menuItem.dom.item.addEventListener("pointerleave", (event) => { // Exit out of the event if it was not made by a mouse. if (event.pointerType === "pen" || event.pointerType === "touch") { return; } if (this.hoverType === "on") { if (this.leaveDelay > 0) { this._clearTimeout(); this._setTimeout(() => { this.currentEvent = "mouse"; menuItem.elements.toggle.close(); }, this.leaveDelay); } else { this.currentEvent = "mouse"; menuItem.elements.toggle.close(); } } else if (this.hoverType === "dynamic") { if (this.leaveDelay > 0) { this._clearTimeout(); this._setTimeout(() => { this.currentEvent = "mouse"; }, this.leaveDelay); } else { this.currentEvent = "mouse"; } } }); // Clear hover timeouts any time the mouse enters an item with a submenu. This prevents the // menu from closing if the mouse leaves but then re-enters before leaveDelay has elapsed. menuItem.dom.item.addEventListener("pointerenter", (event) => { // Exit out of the event if it was not made by a mouse. if (event.pointerType === "pen" || event.pointerType === "touch") { return; } if ( menuItem.isSubmenuItem && (this.hoverType === "on" || this.hoverType === "dynamic") && this.leaveDelay > 0 ) { this._clearTimeout(); } }); } }); } /** * Handles keydown events throughout the menu for proper menu use. * * This method exists to assist the _handleKeyup method. * * - Adds a `keydown` listener to the menu's controller (if the menu is the root menu). * - Blocks propagation on "Space", "Enter", and "Escape" keys. * * @protected */ _handleKeydown() { if (this.isTopLevel && this.elements.controller) { this.elements.controller.dom.toggle.addEventListener( "keydown", (event) => { this.currentEvent = "keyboard"; const key = keyPress(event); if (key === "Space" || key === "Enter") { preventEvent(event); } } ); } } /** * Handles keyup events throughout the menu for proper menu use. * * - Adds a `keyup` listener to the menu's controller (if the menu is the root menu). * - Toggles the menu when the user hits "Space" or "Enter". * * @protected */ _handleKeyup() { if (this.isTopLevel && this.elements.controller) { this.elements.controller.dom.toggle.addEventListener("keyup", (event) => { this.currentEvent = "keyboard"; const key = keyPress(event); if (key === "Space" || key === "Enter") { preventEvent(event); this.elements.controller.toggle(); // If the menu is open, focus the first child. if (this.elements.controller.isOpen) { this.focusFirstChild(); } } }); } } /** * Sets the transition durations of the menu as a CSS custom properties. * * The custom properties are: * - `--am-transition-duration`, * - `--am-open-transition-duration`, and * - `--am-close-transition-duration`. * * The prefix of `am-` can be changed by setting the menu's prefix value. * * @protected */ _setTransitionDurations() { this.dom.menu.style.setProperty( `--${this.prefix}transition-duration`, `${this.transitionDuration}ms` ); this.dom.menu.style.setProperty( `--${this.prefix}open-transition-duration`, `${this.openDuration}ms` ); this.dom.menu.style.setProperty( `--${this.prefix}close-transition-duration`, `${this.closeDuration}ms` ); } /** * Focus the menu. * * Sets the menu's focus state to "self" and * focusses the menu if the menu's shouldFocus * value is `true`. * * @public */ focus() { this.focusState = "self"; if (this.shouldFocus) { this.dom.menu.focus(); } } /** * Unfocus the menu. * * Sets the menu's focus state to "none" * and blurs the menu if the menu's shouldFocus * value is `true`. * * @public */ blur() { this.focusState = "none"; if (this.shouldFocus) { this.dom.menu.blur(); } } /** * Focus the menu's current child. * * @public */ focusCurrentChild() { this.focusState = "self"; if (this.currentChild !== -1) { this.currentMenuItem.focus(); } } /** * Focuses the menu's child at a given index. * * @public * * @param {number} index - The index of the child to focus. */ focusChild(index) { this.blurCurrentChild(); this.currentChild = index; this.focusCurrentChild(); } /** * Focuses the menu's first child. * * @public */ focusFirstChild() { this.focusChild(0); } /** * Focus the menu's last child. * * @public */ focusLastChild() { this.focusChild(this.elements.menuItems.length - 1); } /** * Focus the menu's next child. * * @public */