UNPKG

accessible-menu

Version:

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

633 lines (585 loc) 24.5 kB
import BaseMenu from "./_baseMenu.js"; import MenubarItem from "./menubarItem.js"; import MenubarToggle from "./menubarToggle.js"; import { keyPress, preventEvent } from "./eventHandlers.js"; /** * An accessible menubar navigation in the DOM. * * See Navigation Menubar Example * * @extends BaseMenu * * @example * // Import the class. * import { Menubar } from "accessible-menu"; * * // Select the desired menu element. * const menuElement = document.querySelector("nav ul"); * * // Create the menu. * const menu = new Menubar({ * menuElement, * }); */ class Menubar extends BaseMenu { /** * The class to use when generating submenus. * * @protected * * @type {typeof Menubar} */ _MenuType = Menubar; /** * The class to use when generating menu items. * * @protected * * @type {typeof MenubarItem} */ _MenuItemType = MenubarItem; /** * The class to use when generating submenu toggles. * * @protected * * @type {typeof MenubarToggle} */ _MenuToggleType = MenubarToggle; /** * Constructs a new `Menubar`. * * @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.isTopLevel = true] - A flag to mark the root menu. * @param {?Menubar} [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 {?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 = "a", submenuSelector = "ul", controllerElement = null, containerElement = null, openClass = "show", closeClass = "hide", transitionClass = "transitioning", transitionDuration = 250, isTopLevel = true, parentMenu = null, hoverType = "off", hoverDelay = 250, enterDelay = -1, leaveDelay = -1, prefix = "am-", initialize = true, }) { super({ menuElement, menuItemSelector, menuLinkSelector, submenuItemSelector, submenuToggleSelector, submenuSelector, controllerElement, containerElement, openClass, closeClass, transitionClass, transitionDuration, isTopLevel, parentMenu, hoverType, hoverDelay, enterDelay, leaveDelay, prefix, }); if (initialize) { this.initialize(); } } /** * Initializes the menu. * * Initialize will call the BaseMenu's initialize method * as well as set up focus, * click, * hover, * keydown, and * keyup events for the menu. * * This will also set the menu's `role` to "menubar" in the DOM. * * If the menu is a root menu the first menu item's `tabIndex` will be set to * 0 in the DOM. * * If the BaseMenu's initialize method throws an error, * this will catch it and log it to the console. */ initialize() { try { super.initialize(); // Set the role of the menu. if (this.isTopLevel) { this.dom.menu.setAttribute("role", "menubar"); } else { this.dom.menu.setAttribute("role", "menu"); } this._handleFocus(); this._handleClick(); this._handleHover(); this._handleKeydown(); this._handleKeyup(); if (this.isTopLevel) { this.elements.menuItems[0].dom.link.tabIndex = 0; // Remove the aria-haspopup attribute from the controller. // It isn't needed for the root toggle. if (this.elements.controller) { this.elements.controller.dom.toggle.removeAttribute("aria-haspopup"); } } } catch (error) { console.error(error); } } /** * 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: "ArrowUp", "ArrowRight", * "ArrowDown", "ArrowLeft", "Home", "End", "Space", "Enter", "Escape", * and "A" through "Z". * - Completely closes the menu and moves focus out if the "Tab" key is pressed. * * @protected */ _handleKeydown() { super._handleKeydown(); this.dom.menu.addEventListener("keydown", (event) => { this.currentEvent = "keyboard"; const key = keyPress(event); if (key === "Tab") { // Hitting Tab: // - Moves focus out of the menu. if (this.elements.rootMenu.focusState !== "none") { this.elements.rootMenu.blur(); this.elements.rootMenu.closeChildren(); } else { this.elements.rootMenu.focus(); } } // Prevent default event actions if we're handling the keyup event. if (key === "Character") { preventEvent(event); } else if (this.isTopLevel) { if (this.focusState === "self") { const keys = [ "Space", "Enter", "ArrowRight", "ArrowLeft", "Home", "End", ]; const submenuKeys = ["ArrowDown", "ArrowUp"]; const controllerKeys = ["Escape"]; if (keys.includes(key)) { preventEvent(event); } else if ( this.currentMenuItem.isSubmenuItem && submenuKeys.includes(key) ) { preventEvent(event); } else if (this.elements.controller && controllerKeys.includes(key)) { preventEvent(event); } } } else { const keys = [ "Space", "Enter", "Escape", "ArrowRight", "ArrowLeft", "ArrowDown", "ArrowUp", "Home", "End", ]; if (keys.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 * Navigation Menubar Example): * * <strong>Menubar</strong> * * | Key | Function | * | --- | --- | * | _Space_ or _Enter_ | Opens submenu and moves focus to first item in the submenu. | * | _Right Arrow_ | <ul><li>Moves focus to the next item in the menubar.</li><li>If focus is on the last item, moves focus to the first item.</li></ul> | * | _Left Arrow_ | <ul><li>Moves focus to the previous item in the menubar.</li><li>If focus is on the first item, moves focus to the last item.</li></ul> | * | _Down Arrow_ | Opens submenu and moves focus to first item in the submenu. | * | _Up Arrow_ | Opens submenu and moves focus to last item in the submenu. | * | _Home_ | Moves focus to first item in the menubar. | * | _End_ | Moves focus to last item in the menubar. | * | _Character_ | <ul><li>Moves focus to next item in the menubar having a name that starts with the typed character.</li><li>If none of the items have a name starting with the typed character, focus does not move.</li></ul> | * * <strong>Submenu</strong> * * | Key | Function | * | --- | --- | * | _Space_ or _Enter_ | <ul><li>Activates menu item, causing the link to be activated.</li><li>NOTE: the links go to dummy pages; use the browser go-back function to return to this menubar example page.</li></ul> | * | _Escape_ | <ul><li>Closes submenu.</li><li>Moves focus to parent menubar item.</li></ul> | * | _Right Arrow_ | <ul><li>If focus is on an item with a submenu, opens the submenu and places focus on the first item.</li><li>If focus is on an item that does not have a submenu:<ul><li>Closes submenu.</li><li>Moves focus to next item in the menubar.</li><li>Opens submenu of newly focused menubar item, keeping focus on that parent menubar item.</li></ul></li></ul> | * | _Left Arrow_ | <ul><li>Closes submenu and moves focus to parent menu item.</li><li>If parent menu item is in the menubar, also:<ul><li>moves focus to previous item in the menubar.</li><li>Opens submenu of newly focused menubar item, keeping focus on that parent menubar item.</li></ul></li></ul> | * | _Down Arrow_ | <ul><li>Moves focus to the next item in the submenu.</li><li>If focus is on the last item, moves focus to the first item.</li></ul> | * | _Up Arrow_ | <ul><li>Moves focus to previous item in the submenu.</li><li>If focus is on the first item, moves focus to the last item.</li></ul> | * | Home | Moves focus to the first item in the submenu. | * | End | Moves focus to the last item in the submenu. | * | _Character_ | <ul><li>Moves focus to the next item having a name that starts with the typed character.</li><li>If none of the items have a name starting with the typed character, focus does not move.</li></ul> | * * @protected */ _handleKeyup() { super._handleKeyup(); this.dom.menu.addEventListener("keyup", (event) => { this.currentEvent = "keyboard"; const key = keyPress(event); const { altKey, crtlKey, metaKey } = event; const modifier = altKey || crtlKey || metaKey; if (key === "Character" && !modifier) { // Hitting Character: // - Moves focus to next item in the menubar having a name that starts with the typed character. // - If none of the items have a name starting with the typed character, focus does not move. preventEvent(event); this.elements.rootMenu.currentEvent = "character"; this.focusNextChildWithCharacter(event.key); } else if (this.isTopLevel) { if (this.focusState === "self") { if (key === "Space" || key === "Enter") { // Hitting Space or Enter: // - Opens submenu and moves focus to first item in the submenu. preventEvent(event); if (this.currentMenuItem.isSubmenuItem) { this.currentMenuItem.elements.childMenu.currentEvent = "keyboard"; this.currentMenuItem.elements.toggle.open(); // This ensures the the menu is _visually_ open before the child is focussed. requestAnimationFrame(() => { this.currentMenuItem.elements.childMenu.focusFirstChild(); }); } else { this.currentMenuItem.dom.link.click(); } } else if (key === "ArrowRight") { // Hitting the Right Arrow: // - Moves focus to the next item in the menubar. // - If focus is on the last item, moves focus to the first item. // - If focus was on an open submenu and the newly focussed item has a submenu, open the submenu. preventEvent(event); // Store the current item's info if its an open dropdown. const previousChildOpen = this.currentMenuItem.isSubmenuItem && this.currentMenuItem.elements.toggle.isOpen; this.focusNextChild(); // Open the newly focussed submenu if applicable. if (previousChildOpen) { if (this.currentMenuItem.isSubmenuItem) { this.currentMenuItem.elements.childMenu.currentEvent = "keyboard"; this.currentMenuItem.elements.toggle.preview(); } else { this.closeChildren(); } } } else if (key === "ArrowLeft") { // Hitting the Left Arrow: // - Moves focus to the previous item in the menubar. // - If focus is on the first item, moves focus to the last item. // - If focus was on an open submenu and the newly focussed item has a submenu, open the submenu. preventEvent(event); // Store the current item's info if its an open dropdown. const previousChildOpen = this.currentMenuItem.isSubmenuItem && this.currentMenuItem.elements.toggle.isOpen; this.focusPreviousChild(); // Open the newly focussed submenu if applicable. if (previousChildOpen) { if (this.currentMenuItem.isSubmenuItem) { this.currentMenuItem.elements.childMenu.currentEvent = "keyboard"; this.currentMenuItem.elements.toggle.preview(); } else { this.closeChildren(); } } } else if (key === "ArrowDown") { // Hitting the Down Arrow: // - Opens submenu and moves focus to first item in the submenu. if (this.currentMenuItem.isSubmenuItem) { preventEvent(event); this.currentMenuItem.elements.childMenu.currentEvent = "keyboard"; this.currentMenuItem.elements.toggle.open(); // This ensures the the menu is _visually_ open before the child is focussed. requestAnimationFrame(() => { this.currentMenuItem.elements.childMenu.focusFirstChild(); }); } } else if (key === "ArrowUp") { // Hitting the Up Arrow: // - Opens submenu and moves focus to last item in the submenu. if (this.currentMenuItem.isSubmenuItem) { preventEvent(event); this.currentMenuItem.elements.childMenu.currentEvent = "keyboard"; this.currentMenuItem.elements.toggle.open(); // This ensures the the menu is _visually_ open before the child is focussed. requestAnimationFrame(() => { this.currentMenuItem.elements.childMenu.focusLastChild(); }); } } else if (key === "Home") { // Hitting Home: // - Moves focus to first item in the menubar. preventEvent(event); this.focusFirstChild(); } else if (key === "End") { // Hitting End: // - Moves focus to last item in the menubar. preventEvent(event); this.focusLastChild(); } else if (key === "Escape") { // Hitting Escape: // - Closes menu. const hasOpenChild = this.elements.submenuToggles.some( (toggle) => toggle.isOpen ); if (hasOpenChild) { preventEvent(event); this.closeChildren(); } else if ( this.isTopLevel && this.elements.controller && this.elements.controller.isOpen ) { preventEvent(event); this.elements.controller.close(); this.focusController(); } } } } else { if (key === "Space" || key === "Enter") { // Hitting Space or Enter: // - Activates menu item, causing the link to be activated. preventEvent(event); if (this.currentMenuItem.isSubmenuItem) { this.currentMenuItem.elements.childMenu.currentEvent = "keyboard"; this.currentMenuItem.elements.toggle.open(); // This ensures the the menu is _visually_ open before the child is focussed. requestAnimationFrame(() => { this.currentMenuItem.elements.childMenu.focusFirstChild(); }); } else { this.currentMenuItem.dom.link.click(); } } else if (key === "Escape") { // Hitting Escape: // - Closes submenu. // - Moves focus to parent menubar item. preventEvent(event); this.elements.rootMenu.closeChildren(); this.elements.rootMenu.focusCurrentChild(); } else if (key === "ArrowRight") { // Hitting the Right Arrow: // - If focus is on an item with a submenu, opens the submenu and places focus on the first item. // - If focus is on an item that does not have a submenu: // - Closes submenu. // - Moves focus to next item in the menubar. // - Opens submenu of newly focused menubar item, keeping focus on that parent menubar item. if (this.currentMenuItem.isSubmenuItem) { preventEvent(event); this.currentMenuItem.elements.childMenu.currentEvent = "keyboard"; this.currentMenuItem.elements.toggle.open(); // This ensures the the menu is _visually_ open before the child is focussed. requestAnimationFrame(() => { this.currentMenuItem.elements.childMenu.focusFirstChild(); }); } else { preventEvent(event); this.elements.rootMenu.closeChildren(); this.elements.rootMenu.focusNextChild(); if (this.elements.rootMenu.currentMenuItem.isSubmenuItem) { this.elements.rootMenu.currentMenuItem.elements.toggle.preview(); } } } else if (key === "ArrowLeft") { // Hitting the Left Arrow: // - Closes submenu and moves focus to parent menu item. // - If parent menu item is in the menubar, also: // - moves focus to previous item in the menubar. // - Opens submenu of newly focused menubar item, keeping focus on that parent menubar item. if (this.elements.parentMenu.currentMenuItem.isSubmenuItem) { preventEvent(event); this.elements.parentMenu.currentMenuItem.elements.toggle.close(); this.elements.parentMenu.focusCurrentChild(); if (this.elements.parentMenu === this.elements.rootMenu) { this.elements.rootMenu.closeChildren(); this.elements.rootMenu.focusPreviousChild(); if (this.elements.rootMenu.currentMenuItem.isSubmenuItem) { this.elements.rootMenu.currentMenuItem.elements.childMenu.currentEvent = "keyboard"; this.elements.rootMenu.currentMenuItem.elements.toggle.preview(); } } } } else if (key === "ArrowDown") { // Hitting the Down Arrow: // - Moves focus to the next item in the menubar. // - If focus is on the last item, moves focus to the first item. preventEvent(event); this.focusNextChild(); } else if (key === "ArrowUp") { // Hitting the Up Arrow: // - Moves focus to the previous item in the menubar. // - If focus is on the first item, moves focus to the last item. preventEvent(event); this.focusPreviousChild(); } else if (key === "Home") { // Hitting Home: // - Moves focus to first item in the menubar. preventEvent(event); this.focusFirstChild(); } else if (key === "End") { // Hitting End: // - Moves focus to last item in the menubar. preventEvent(event); this.focusLastChild(); } } }); } /** * Focus the menu's next child. * * If the currently focussed child in the menu is the last child then this will * focus the first child in the menu. * * @public */ focusNextChild() { // If the current child is the last child of the menu, focus the menu's first child. if (this.currentChild === this.elements.menuItems.length - 1) { this.focusFirstChild(); } else { this.focusChild(this.currentChild + 1); } } /** * Focus the menu's previous child. * * If the currently focussed child in the menu is the first child then this will * focus the last child in the menu. * * @public */ focusPreviousChild() { // If the current child is the first child of the menu, focus the menu's last child. if (this.currentChild === 0) { this.focusLastChild(); } else { this.focusChild(this.currentChild - 1); } } /** * Focus the menu's next child starting with a specific letter. * * @public * * @param {string} char - The character to look for. */ focusNextChildWithCharacter(char) { // Ensure the character is lowercase just to be safe. const match = char.toLowerCase(); let index = this.currentChild + 1; let found = false; while (!found && index < this.elements.menuItems.length) { let text = ""; // Attempt to use the browser to get proper innerText, // otherwise fall back to textContent. if (this.elements.menuItems[index].dom.item.innerText) { text = this.elements.menuItems[index].dom.item.innerText; } else { text = this.elements.menuItems[index].dom.item.textContent; } // Remove spaces, make lowercase, and grab the first chracter of the string. text = text.replace(/[\s]/g, "").toLowerCase().charAt(0); // Focus the child if the text matches, otherwise move on. if (text === match) { found = true; this.focusChild(index); } index++; } } } export default Menubar;