accessible-menu
Version:
A JavaScript library to help you generate WCAG accessible menus in the DOM.
1,593 lines • 147 kB
JavaScript
function w(r, e) {
r === "" || r.length === 0 || (typeof r == "string" ? e.classList.add(r) : e.classList.add(...r));
}
function k(r, e) {
r === "" || r.length === 0 || (typeof r == "string" ? e.classList.remove(r) : e.classList.remove(...r));
}
function A(r, e) {
try {
if (typeof e != "object") {
const t = typeof e;
throw new TypeError(
`Elements given to isValidInstance() must be inside of an object. "${t}" given.`
);
}
for (const t in e)
if (!(e[t] instanceof r)) {
const s = typeof e[t];
throw new TypeError(
`${t} must be an instance of ${r.name}. "${s}" given.`
);
}
return {
status: !0,
error: null
};
} catch (t) {
return {
status: !1,
error: t
};
}
}
function h(r, e) {
try {
if (typeof e != "object") {
const t = typeof e;
throw new TypeError(
`Values given to isValidType() must be inside of an object. "${t}" given.`
);
}
for (const t in e) {
const s = typeof e[t];
if (s !== r)
throw new TypeError(`${t} must be a ${r}. "${s}" given.`);
}
return {
status: !0,
error: null
};
} catch (t) {
return {
status: !1,
error: t
};
}
}
function x(r) {
try {
if (typeof r != "object") {
const e = typeof r;
throw new TypeError(
`Values given to isQuerySelector() must be inside of an object. "${e}" given.`
);
}
for (const e in r)
try {
if (r[e] === null)
throw new Error();
document.querySelector(r[e]);
} catch {
throw new TypeError(
`${e} must be a valid query selector. "${r[e]}" given.`
);
}
return {
status: !0,
error: null
};
} catch (e) {
return {
status: !1,
error: e
};
}
}
function D(r) {
try {
if (typeof r != "object" || Array.isArray(r)) {
const e = typeof r;
throw new TypeError(
`Values given to isValidClassList() must be inside of an object. "${e}" given.`
);
}
for (const e in r) {
const t = typeof r[e];
if (t !== "string")
if (Array.isArray(r[e]))
r[e].forEach((s) => {
if (typeof s != "string")
throw new TypeError(
`${e} must be a string or an array of strings. An array containing non-strings given.`
);
});
else
throw new TypeError(
`${e} must be a string or an array of strings. "${t}" given.`
);
else {
const s = {};
s[e] = r[e], x(s);
}
}
return {
status: !0,
error: null
};
} catch (e) {
return {
status: !1,
error: e
};
}
}
function V(r) {
try {
if (typeof r != "object") {
const t = typeof r;
throw new TypeError(
`Values given to isValidState() must be inside of an object. "${t}" given.`
);
}
const e = ["none", "self", "child"];
for (const t in r)
if (!e.includes(r[t]))
throw new TypeError(
`${t} must be one of the following values: ${e.join(
", "
)}. "${r[t]}" given.`
);
return {
status: !0,
error: null
};
} catch (e) {
return {
status: !1,
error: e
};
}
}
function P(r) {
try {
if (typeof r != "object") {
const t = typeof r;
throw new TypeError(
`Values given to isValidEvent() must be inside of an object. "${t}" given.`
);
}
const e = ["none", "mouse", "keyboard", "character"];
for (const t in r)
if (!e.includes(r[t]))
throw new TypeError(
`${t} must be one of the following values: ${e.join(
", "
)}. "${r[t]}" given.`
);
return {
status: !0,
error: null
};
} catch (e) {
return {
status: !1,
error: e
};
}
}
function j(r) {
try {
if (typeof r != "object") {
const t = typeof r;
throw new TypeError(
`Values given to isValidHoverType() must be inside of an object. "${t}" given.`
);
}
const e = ["off", "on", "dynamic"];
for (const t in r)
if (!e.includes(r[t]))
throw new TypeError(
`${t} must be one of the following values: ${e.join(
", "
)}. "${r[t]}" given.`
);
return {
status: !0,
error: null
};
} catch (e) {
return {
status: !1,
error: e
};
}
}
function $(r, e) {
if (h("string", { tagName: r }).status && A(HTMLElement, e).status) {
const t = r.toLowerCase();
let s = !0;
for (const n in e)
e[n].tagName.toLowerCase() !== t && (s = !1);
return s;
} else
return !1;
}
class L {
/**
* The DOM elements within the menu toggle.
*
* @protected
*
* @type {Object<HTMLElement>}
*
* @property {HTMLElement} toggle - The menu toggle.
* @property {HTMLElement} parent - The menu containing this toggle.
*/
_dom = {
toggle: null,
parent: null
};
/**
* The declared accessible-menu elements within the menu toggle.
*
* @protected
*
* @type {Object<BaseMenu>}
*
* @property {BaseMenu} controlledMenu - The menu controlled by this toggle.
* @property {BaseMenu} parentMenu - The menu containing this toggle.
*/
_elements = {
controlledMenu: null,
parentMenu: null
};
/**
* The open state of the menu toggle.
*
* @protected
*
* @type {boolean}
*/
_open = !1;
/**
* The event that is triggered when the menu toggle expands.
*
* @protected
*
* @event accessibleMenuExpand
*
* @type {CustomEvent}
*
* @property {boolean} bubbles - A flag to bubble the event.
* @property {Object<BaseMenuToggle>} details - The details object containing the BaseMenuToggle itself.
*/
_expandEvent = new CustomEvent("accessibleMenuExpand", {
bubbles: !0,
detail: { toggle: this }
});
/**
* The event that is triggered when the menu toggle collapses.
*
* @protected
*
* @event accessibleMenuCollapse
*
* @type {CustomEvent}
*
* @property {boolean} bubbles - A flag to bubble the event.
* @property {Object<BaseMenuToggle>} details - The details object containing the BaseMenuToggle itself.
*/
_collapseEvent = new CustomEvent("accessibleMenuCollapse", {
bubbles: !0,
detail: { toggle: this }
});
/**
* Constructs a new `BaseMenuToggle`.
*
* @param {object} options - The options for generating the menu toggle.
* @param {HTMLElement} options.menuToggleElement - The toggle element in the DOM.
* @param {HTMLElement} options.parentElement - The element containing the controlled menu.
* @param {BaseMenu} options.controlledMenu - The menu controlled by this toggle.
* @param {?BaseMenu} [options.parentMenu = null] - The menu containing this toggle.
*/
constructor({
menuToggleElement: e,
parentElement: t,
controlledMenu: s,
parentMenu: n = null
}) {
this._dom.toggle = e, this._dom.parent = t, this._elements.controlledMenu = s, this._elements.parentMenu = n;
}
/**
* Initializes the menu toggle.
*
* The first steps are to ensure that the toggle and controlled menu have IDs
* using the setIds method, and to set the ARIA attributes on the toggle
* and controlled menu using the setAriaAttributes method.
*
* Then the collapse method is called to make sure the submenu is closed.
*/
initialize() {
this._setIds(), this._setAriaAttributes(), this._collapse(!1);
}
/**
* The DOM elements within the toggle.
*
* @readonly
*
* @type {Object<HTMLElement>}
*
* @see _dom
*/
get dom() {
return this._dom;
}
/**
* The declared accessible-menu elements within the toggle.
*
* @readonly
*
* @type {Object<BaseMenu>}
*
* @see _elements
*/
get elements() {
return this._elements;
}
/**
* The open state on the toggle.
*
* @type {boolean}
*
* @see _open
*/
get isOpen() {
return this._open;
}
set isOpen(e) {
h("boolean", { isOpen: e }), this._open = e;
}
/**
* Sets unique IDs for the toggle and controlled menu.
*
* If the toggle and controlled menu do not have IDs, the following steps take place:
* - Get the innerText of the toggle,
* - Set the toggle's ID to: `menu-button-${toggle-inner-text}-${key}`
* - Set the menu's ID to: `menu-${toggle-inner-text}-${key}`
*
* @protected
*/
_setIds() {
if (this.dom.toggle.id === "" || this.elements.controlledMenu.dom.menu.id === "") {
let e = this.dom.toggle.innerText?.replace(/[^a-zA-Z0-9\s]/g, "") || "", t = this.elements.controlledMenu.key;
!e.replace(/\s/g, "").length && this.dom.toggle.getAttribute("aria-label") && (e = this.dom.toggle.getAttribute("aria-label").replace(/[^a-zA-Z0-9\s]/g, "")), e.replace(/\s/g, "").length > 0 && (e = e.toLowerCase().replace(/\s+/g, "-"), e.startsWith("-") && (e = e.substring(1)), e.endsWith("-") && (e = e.slice(0, -1)), t = `${e}-${t}`), this.dom.toggle.id = this.dom.toggle.id || `menu-button-${t}`, this.elements.controlledMenu.dom.menu.id = this.elements.controlledMenu.dom.menu.id || `menu-${t}`;
}
}
/**
* Sets the ARIA attributes on the toggle and controlled menu.
*
* The first steps are to ensure that the toggle has `aria-expanded`
* is initially set to "false".
*
* Then using the toggle and menu's IDs, the menu's `aria-labelledby` is set to
* the toggle's ID.
*
* @protected
*/
_setAriaAttributes() {
this.dom.toggle.setAttribute("aria-expanded", "false"), this.elements.controlledMenu.dom.menu.setAttribute(
"aria-labelledby",
this.dom.toggle.id
);
}
/**
* Expands the controlled menu.
*
* Sets the toggle's `aria-expanded` to "true", adds the
* open class to the toggle's parent menu item
* and controlled menu, and removes the closed class
* from the toggle's parent menu item and controlled menu.
*
* If `emit` is set to `true`, this will also emit a custom event
* called accessibleMenuExpand
*
* @protected
*
* @fires accessibleMenuExpand
*
* @param {boolean} [emit = true] - A toggle to emit the expand event once expanded.
*/
_expand(e = !0) {
const { closeClass: t, openClass: s, transitionClass: n, openDuration: i } = this.elements.controlledMenu;
this.dom.toggle.setAttribute("aria-expanded", "true"), this.elements.controlledMenu.elements.rootMenu.hasOpened = !0, n !== "" ? (w(n, this.elements.controlledMenu.dom.menu), requestAnimationFrame(() => {
k(t, this.elements.controlledMenu.dom.menu), requestAnimationFrame(() => {
w(s, this.elements.controlledMenu.dom.menu), requestAnimationFrame(() => {
setTimeout(() => {
k(
n,
this.elements.controlledMenu.dom.menu
);
}, i);
});
});
})) : (w(s, this.elements.controlledMenu.dom.menu), k(t, this.elements.controlledMenu.dom.menu)), e && this.dom.toggle.dispatchEvent(this._expandEvent);
}
/**
* Collapses the controlled menu.
*
* Sets the toggle's `aria-expanded` to "false", adds the
* closed class to the toggle's parent menu item
* and controlled menu, and removes the open class
* from the toggle's parent menu item and controlled menu.
*
* If `emit` is set to `true`, this will also emit a custom event
* called accessibleMenuCollapse
*
* @protected
*
* @fires accessibleMenuCollapse
*
* @param {boolean} [emit = true] - A toggle to emit the collapse event once collapsed.
*/
_collapse(e = !0) {
const { closeClass: t, openClass: s, transitionClass: n, closeDuration: i } = this.elements.controlledMenu;
this.dom.toggle.setAttribute("aria-expanded", "false"), n !== "" ? (w(n, this.elements.controlledMenu.dom.menu), requestAnimationFrame(() => {
k(s, this.elements.controlledMenu.dom.menu), requestAnimationFrame(() => {
w(t, this.elements.controlledMenu.dom.menu), requestAnimationFrame(() => {
setTimeout(() => {
k(
n,
this.elements.controlledMenu.dom.menu
);
}, i);
});
});
})) : (w(t, this.elements.controlledMenu.dom.menu), k(s, this.elements.controlledMenu.dom.menu)), e && this.dom.toggle.dispatchEvent(this._collapseEvent);
}
/**
* Opens the controlled menu.
*
* Sets the controlled menu's focus state to "self"
* and the parent menu's focus state to "child", calls expand,
* and sets the isOpen value to `true`.
*
* @public
*/
open() {
this.elements.controlledMenu.focusState = "self", this.isOpen || (this._expand(), this.isOpen = !0);
}
/**
* Opens the controlled menu without the current focus entering it.
*
* Sets the controlled menu's focus state to "self"
* and the parent menu's focus state to "child",
* and calls expand.
*
* @public
*/
preview() {
this.elements.parentMenu && (this.elements.parentMenu.focusState = "self"), this.isOpen || (this._expand(), this.isOpen = !0);
}
/**
* Closes the controlled menu.
*
* Sets the controlled menu's focus state to "none"
* and the parent menu's focus state to "self", blurs the controlled menu
* and sets it's current child index to 0,
* calls collapse, and sets
* the isOpen value to `false`.
*
* @public
*/
close() {
this.isOpen && (this.elements.controlledMenu.blur(), this.elements.parentMenu && (this.elements.parentMenu.focusState = "self"), this._collapse(), this.isOpen = !1);
}
/**
* Toggles the open state of the controlled menu between `true` and `false`.
*
* @public
*/
toggle() {
this.isOpen ? this.close() : this.open();
}
/**
* Closes all sibling menus.
*
* @public
*/
closeSiblings() {
this.elements.parentMenu && this.elements.parentMenu.elements.submenuToggles.forEach((e) => {
e !== this && e.close();
});
}
/**
* Closes all child menus.
*
* @public
*/
closeChildren() {
this.elements.controlledMenu.elements.submenuToggles.forEach(
(e) => e.close()
);
}
}
class O {
/**
* The DOM elements within the menu item.
*
* @protected
*
* @type {Object<HTMLElement>}
*
* @property {HTMLElement} item - The menu item.
* @property {HTMLElement} link - The menu item's link.
*/
_dom = {
item: null,
link: null
};
/**
* The declared accessible-menu elements within the menu item.
*
* @protected
*
* @type {Object<BaseMenu, BaseMenuToggle>}
*
* @property {BaseMenu} parentMenu - The menu containing this menu item.
* @property {?BaseMenu} childMenu - The menu contained within this menu item.
* @property {?BaseMenuToggle} toggle - The menu toggle within this menu item that controls the `childMenu`.
*/
_elements = {
parentMenu: null,
childMenu: null,
toggle: null
};
/**
* A flag marking a submenu item.
*
* @protected
*
* @type {boolean}
*/
_submenu = !1;
/**
* Constructs a new `BaseMenuItem`.
*
* @param {object} options - The options for generating the menu item.
* @param {HTMLElement} options.menuItemElement - The menu item in the DOM.
* @param {HTMLElement} options.menuLinkElement - The menu item's link in the DOM.
* @param {BaseMenu} options.parentMenu - The parent menu.
* @param {boolean} [options.isSubmenuItem = false] - A flag to mark if the menu item is controlling a submenu.
* @param {?BaseMenu} [options.childMenu = null] - The child menu.
* @param {?BaseMenuToggle} [options.toggle = null] - The controller for the child menu.
*/
constructor({
menuItemElement: e,
menuLinkElement: t,
parentMenu: s,
isSubmenuItem: n = !1,
childMenu: i = null,
toggle: l = null
}) {
this._dom.item = e, this._dom.link = t, this._elements.parentMenu = s, this._elements.childMenu = i, this._elements.toggle = l, this._submenu = n;
}
/**
* Initialize the menu item.
*/
initialize() {
}
/**
* The DOM elements within the menu item.
*
* @readonly
*
* @type {Object<HTMLElement>}
*
* @see _dom
*/
get dom() {
return this._dom;
}
/**
* The declared accessible-menu elements within the menu item.
*
* @readonly
*
* @type {Object<BaseMenu, BaseMenuToggle>}
*
* @see _elements
*/
get elements() {
return this._elements;
}
/**
* A flag marking a submenu item.
*
* @readonly
*
* @type {boolean}
*
* @see _submenu
*/
get isSubmenuItem() {
return this._submenu;
}
/**
* Focuses the menu item's link if the parent menu's
* shouldFocus value is `true`.
*
* @public
*/
focus() {
this.elements.parentMenu.shouldFocus && requestAnimationFrame(() => {
this.dom.link.focus();
});
}
/**
* Blurs the menu item's link if the parent menu's
* shouldFocus value is `true`.
*
* @public
*/
blur() {
this.elements.parentMenu.shouldFocus && requestAnimationFrame(() => {
this.dom.link.blur();
});
}
}
function y(r) {
try {
const e = r.key || r.keyCode, t = {
Enter: e === "Enter" || e === 13,
Space: e === " " || e === "Spacebar" || e === 32,
Escape: e === "Escape" || e === "Esc" || e === 27,
ArrowUp: e === "ArrowUp" || e === "Up" || e === 38,
ArrowRight: e === "ArrowRight" || e === "Right" || e === 39,
ArrowDown: e === "ArrowDown" || e === "Down" || e === 40,
ArrowLeft: e === "ArrowLeft" || e === "Left" || e === 37,
Home: e === "Home" || e === 36,
End: e === "End" || e === 35,
Character: isNaN(e) && !!e.match(/^[a-zA-Z]{1}$/),
Tab: e === "Tab" || e === 9,
Asterisk: e === "*" || e === 56
};
return Object.keys(t).find((s) => t[s] === !0) || "";
} catch {
return "";
}
}
function o(r) {
r.preventDefault(), r.stopPropagation();
}
class I {
/**
* The class to use when generating submenus.
*
* @protected
*
* @type {typeof BaseMenu}
*/
_MenuType = I;
/**
* The class to use when generating menu items.
*
* @protected
*
* @type {typeof BaseMenuItem}
*/
_MenuItemType = O;
/**
* The class to use when generating submenu toggles.
*
* @protected
*
* @type {typeof BaseMenuToggle}
*/
_MenuToggleType = L;
/**
* 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 = !0;
/**
* 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 = !1;
/**
* The key used to generate IDs throughout the menu.
*
* @protected
*
* @type {string}
*/
_key = "";
/**
* 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.
* @param {?string} [options.key = null] - The key used to generate IDs throughout the menu.
*/
constructor({
menuElement: e,
menuItemSelector: t = "li",
menuLinkSelector: s = "a",
submenuItemSelector: n = "li:has(ul)",
submenuToggleSelector: i = "a",
submenuSelector: l = "ul",
controllerElement: u = null,
containerElement: c = null,
openClass: a = "show",
closeClass: d = "hide",
transitionClass: f = "transitioning",
transitionDuration: p = 250,
openDuration: m = -1,
closeDuration: _ = -1,
isTopLevel: g = !0,
parentMenu: M = null,
hoverType: b = "off",
hoverDelay: C = 250,
enterDelay: T = -1,
leaveDelay: E = -1,
prefix: v = "am-",
key: S = null
}) {
this._dom.menu = e, this._dom.controller = u, this._dom.container = c, this._selectors.menuItems = t, this._selectors.menuLinks = s, this._selectors.submenuItems = n, this._selectors.submenuToggles = i, this._selectors.submenus = l, this._elements.menuItems = [], this._elements.submenuToggles = [], this._elements.controller = null, this._elements.parentMenu = M, this._elements.rootMenu = g ? this : null, this._openClass = a || "", this._closeClass = d || "", this._transitionClass = f || "", this._transitionDuration = p, this._openDuration = m, this._closeDuration = _, this._prefix = v || "", this._key = S || "", this._root = g, this._hoverType = b, this._hoverDelay = C, this._enterDelay = T, this._leaveDelay = E;
}
/**
* 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, set the ID's on the menu, controller,
* and container.
* - If the current menu is the root menu _and_ has a controller, initialize
* the controller.
* - If the current menu is the root menu, add it to the AccessibleMenu storage in the window.
* - 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:
- ${this.errors.join(
`
- `
)}`
);
if (this.elements.rootMenu === null && this._findRootMenu(this), this._generateKey(), this._setDOMElements(), this.isTopLevel) {
if (this._setIds(), this.dom.controller && this.dom.container) {
const e = new this._MenuToggleType({
menuToggleElement: this.dom.controller,
parentElement: this.dom.container,
controlledMenu: this
});
$("button", { toggle: e.dom.toggle }) || e.dom.toggle.setAttribute("role", "button"), e.dom.toggle.setAttribute("aria-controls", this.dom.menu.id), this._elements.controller = e;
}
window.AccessibleMenu = window.AccessibleMenu || {
menus: {}
}, typeof window.AccessibleMenu.menus != "object" && (window.AccessibleMenu.menus = {}), window.AccessibleMenu.menus[this.dom.menu.id] = this;
}
this._createChildElements(), this._setTransitionDurations();
}
/**
* 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 key used to generate IDs throughout the menu.
*
* This functions differently for root vs. submenus.
* Submenus will always inherit their parent menu's key suffixed with their position.
*
* @readonly
*
* @type {string}
*
* @see _key
*/
get key() {
if (this.isTopLevel)
return this._key;
const e = this.elements.parentMenu.dom.submenus.indexOf(this.dom.menu) || 0;
return `${this.elements.parentMenu.key}-${e}`;
}
/**
* An array of error messages generated by the menu.
*
* @readonly
*
* @type {string[]}
*
* @see _errors
*/
get errors() {
return this._errors;
}
/**
* 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;
}
set openClass(e) {
D({ openClass: e }), this._openClass !== e && (this._openClass = e);
}
/**
* 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;
}
set closeClass(e) {
D({ closeClass: e }), this._closeClass !== e && (this._closeClass = e);
}
/**
* 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;
}
set transitionClass(e) {
D({ transitionClass: e }), this._transitionClass !== e && (this._transitionClass = e);
}
/**
* 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;
}
set transitionDuration(e) {
h("number", { transitionDuration: e }), this._transitionDuration !== e && (this._transitionDuration = e, this._setTransitionDurations());
}
/**
* 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() {
return this._openDuration === -1 ? this.transitionDuration : this.isTopLevel ? this._openDuration : this.elements.rootMenu.openDuration;
}
set openDuration(e) {
h("number", { openDuration: e }), this._openDuration !== e && (this._openDuration = e, this._setTransitionDurations());
}
/**
* 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() {
return this._closeDuration === -1 ? this.transitionDuration : this.isTopLevel ? this._closeDuration : this.elements.rootMenu.closeDuration;
}
set closeDuration(e) {
h("number", { closeDuration: e }), this._closeDuration !== e && (this._closeDuration = e, this._setTransitionDurations());
}
/**
* 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;
}
set currentChild(e) {
h("number", { currentChild: e });
function t(s) {
if (["mouse", "character"].includes(s.currentEvent) && s.elements.parentMenu) {
let i = 0, l = !1;
for (; !l && i < s.elements.parentMenu.elements.menuItems.length; ) {
const u = s.elements.parentMenu.elements.menuItems[i];
u.isSubmenuItem && u.elements.toggle.elements.controlledMenu === s && (l = !0, s.elements.parentMenu.currentEvent = s.currentEvent, s.elements.parentMenu.currentChild = i), i++;
}
}
}
e < -1 ? (this._currentChild = -1, t(this)) : e >= this.elements.menuItems.length ? (this._currentChild = this.elements.menuItems.length - 1, t(this)) : this.focusChild !== e && (this._currentChild = e, t(this));
}
/**
* 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;
}
set focusState(e) {
V({ focusState: e }), this._focusState !== e && (this._focusState = e), this.elements.submenuToggles.length > 0 && (e === "self" || e === "none") && this.elements.submenuToggles.forEach((t) => {
t.elements.controlledMenu.focusState = "none";
}), this.elements.parentMenu && (e === "self" || e === "child") && (this.elements.parentMenu.focusState = "child");
}
/**
* The last event triggered on the menu.
*
* @type {string}
*
* @see _currentEvent
*/
get currentEvent() {
return this._currentEvent;
}
set currentEvent(e) {
P({ currentEvent: e }), this._currentEvent !== e && (this._currentEvent = e, this.elements.submenuToggles.length > 0 && this.elements.submenuToggles.forEach((t) => {
t.elements.controlledMenu.currentEvent = e;
}));
}
/**
* 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;
}
set hoverType(e) {
j({ hoverType: e }), this._hoverType !== e && (this._hoverType = e);
}
/**
* 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;
}
set hoverDelay(e) {
h("number", { hoverDelay: e }), this._hoverDelay !== e && (this._hoverDelay = e);
}
/**
* 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() {
return this._enterDelay === -1 ? this.hoverDelay : this._root ? this._enterDelay : this.elements.rootMenu.enterDelay;
}
set enterDelay(e) {
h("number", { enterDelay: e }), this._enterDelay !== e && (this._enterDelay = e);
}
/**
* 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() {
return this._leaveDelay === -1 ? this.hoverDelay : this._root ? this._leaveDelay : this.elements.rootMenu.leaveDelay;
}
set leaveDelay(e) {
h("number", { leaveDelay: e }), this._leaveDelay !== e && (this._leaveDelay = e);
}
/**
* 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;
}
set prefix(e) {
h("string", { prefix: e }), this._prefix !== e && (this._prefix = e);
}
/**
* 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 e = !1;
return (this.currentEvent === "keyboard" || this.currentEvent === "character") && (e = !0), this.currentEvent === "mouse" && this.hoverType === "dynamic" && (e = !0), e;
}
/**
* 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;
}
set hasOpened(e) {
h("boolean", { hasOpened: e }), this._hasOpened !== e && (this._hasOpened = e);
}
/**
* Validates all aspects of the menu to ensure proper functionality.
*
* @protected
*
* @return {boolean} - The result of the validation.
*/
_validate() {
let e = !0, t;
this._dom.container !== null || this._dom.controller !== null ? t = A(HTMLElement, {
menuElement: this._dom.menu,
controllerElement: this._dom.controller,
containerElement: this._dom.container
}) : t = A(HTMLElement, {
menuElement: this._dom.menu
}), t.status || (this._errors.push(t.error.message), e = !1);
let s;
if (this._selectors.submenuItems !== "" ? s = x({
menuItemSelector: this._selectors.menuItems,
menuLinkSelector: this._selectors.menuLinks,
submenuItemSelector: this._selectors.submenuItems,
submenuToggleSelector: this._selectors.submenuToggles,
submenuSelector: this._selectors.submenus
}) : s = x({
menuItemSelector: this._selectors.menuItems,
menuLinkSelector: this._selectors.menuLinks
}), s.status || (this._errors.push(s.error.message), e = !1), this._openClass !== "") {
const m = D({ openClass: this._openClass });
m.status || (this._errors.push(m.error.message), e = !1);
}
if (this._closeClass !== "") {
const m = D({
closeClass: this._closeClass
});
m.status || (this._errors.push(m.error.message), e = !1);
}
if (this._transitionClass !== "") {
const m = D({
transitionClass: this._transitionClass
});
m.status || (this._errors.push(m.error.message), e = !1);
}
const n = h("number", {
transitionDuration: this._transitionDuration
});
n.status || (this._errors.push(n.error.message), e = !1);
const i = h("number", {
openDuration: this._openDuration
});
i.status || (this._errors.push(i.error.message), e = !1);
const l = h("number", {
closeDuration: this._closeDuration
});
l.status || (this._errors.push(l.error.message), e = !1);
const u = h("boolean", { isTopLevel: this._root });
if (u.status || (this._errors.push(u.error.message), e = !1), this._elements.parentMenu !== null) {
const m = A(I, {
parentMenu: this._elements.parentMenu
});
m.status || (this._errors.push(m.error.message), e = !1);
}
const c = j({ hoverType: this._hoverType });
c.status || (this._errors.push(c.error.message), e = !1);
const a = h("number", {
hoverDelay: this._hoverDelay
});
a.status || (this._errors.push(a.error.message), e = !1);
const d = h("number", {
enterDelay: this._enterDelay
});
d.status || (this._errors.push(d.error.message), e = !1);
const f = h("number", {
leaveDelay: this._leaveDelay
});
if (f.status || (this._errors.push(f.error.message), e = !1), this._key !== "") {
const m = h("string", { key: this._key });
m.status || (this._errors.push(m.error.message), e = !1);
}
const p = h("string", { prefix: this._prefix });
return p.status || (this._errors.push(p.error.message), e = !1), e;
}
/**
* 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(e, t = this.dom.menu, s = !0) {
if (typeof this.selectors[e] == "string") {
if (!Array.isArray(this.dom[e]))
throw new Error(
`AccessibleMenu: The "${e}" element cannot be set through _setDOMElementType.`
);
t !== this.dom.menu && A(HTMLElement, { base: t });
const i = Array.from(
t.querySelectorAll(this.selectors[e])
).filter(
(l) => l.parentElement === t
);
s ? this._dom[e] = i : this._dom[e] = [
...this._dom[e],
...i
];
} else
throw new Error(
`AccessibleMenu: "${e}" 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(e) {
if (typeof this.dom[e] < "u") {
if (!Array.isArray(this.dom[e]))
throw new Error(
`AccessibleMenu: The "${e}" element cannot be reset through _resetDOMElementType.`
);
this._dom[e] = [];
} else
throw new Error(
`AccessibleMenu: "${e}" 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"), this.selectors.submenuItems !== "" && (this._setDOMElementType("submenuItems"), this._resetDOMElementType("submenuToggles"), this._resetDOMElementType("submenus"), this.dom.submenuItems.forEach((e) => {
this._setDOMElementType("submenuToggles", e, !1), this._setDOMElementType("submenus", e, !1);
}));
}
/**
* Generates a key for the menu.
*
* @param {boolean} [regenerate = false] - A flag to determine if the key should be regenerated.
*/
_generateKey(e = !1) {
(this.key === "" || e) && (this._key = Math.random().toString(36).replace(/[^a-z]+/g, "").substring(0, 10));
}
/**
* Sets the IDs of the menu and it's elements if they do not already exist.
*
* The generated IDs use the key and follow the format:
* - menu: `menu-${key}`
* - container: `menu-container-${key}`
* - controller: `menu-controller-${key}`
*/
_setIds() {
this.dom.menu.id = this.dom.menu.id || `menu-${this.key}`, this.dom.container && (this.dom.container.id = this.dom.container.id || `menu-container-${this.key}`), this.dom.controller && (this.dom.controller.id = this.dom.controller.id || `menu-controller-${this.key}`);
}
/**
* Finds the root menu element.
*
* @protected
*
* @param {BaseMenu} menu - The menu to check.
*/
_findRootMenu(e) {
if (e.isTopLevel)
this._elements.rootMenu = e;
else if (e.elements.parentMenu !== null)
this._findRootMenu(e.elements.parentMenu);
else
throw new Error("Cannot find root menu.");
}
/**
* Creates and initializes all menu items and submenus.
*
* @protected
*/
_createChildElements() {
this.dom.menuItems.forEach((e) => {
let t;
if (this.dom.submenuItems.includes(e)) {
const s = e.querySelector(this.selectors.submenuToggles), n = e.querySelector(this.selectors.submenus), i = new this._MenuType({
menuElement: n,
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: !1,
parentMenu: this,
hoverType: this.hoverType,
hoverDelay: this.hoverDelay,
enterDelay: this.enterDelay,
leaveDelay: this.leaveDelay
}), l = new this._MenuToggleType({
menuToggleElement: s,
parentElement: e,
controlledMenu: i,
parentMenu: this
});
this._elements.submenuToggles.push(l), t = new this._MenuItemType({
menuI