/** * VDI Main Nav - All Main Navigation Functionality * * Please review documentation upon first use, and, as-needed: * https://docs.vincentdevelopment.ca/docs/starter-v3-enhancements/navigation/ */ /** * Navigation * Handles navigation logic * * @param {string} mobileMenuButtonId * @param {string} dropDownClass * @param {string} subMenuLinkClass */ class Navigation { /** * The main toggle element * * @type {HTMLElement} * @link {https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties} */ #mobileMenuButton; /** * List of sub dropdown buttons * * @type {NodeList} * @link {https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/Private_properties} */ #dropdownButtons; /** * Class name to identify sub menu items (ul>li>a) * @type {string} */ #subMenuElementIdentifier; constructor(mobileMenuButtonId, dropDownClass, subMenuLinkClass = "sub-menu-item") { this.#mobileMenuButton = document.getElementById(mobileMenuButtonId); this.#dropdownButtons = document.querySelectorAll(dropDownClass); // Do not change this to getElementsByClassName, logic is iterating over non-live node list this.#subMenuElementIdentifier = subMenuLinkClass; this.handleEscapeKey(); } /** * Handles main mobile toggling * Adds an event listener to mobile menu dropdown * toggle button */ mobileMenuToggle() { this.#mobileMenuButton.addEventListener("click", (event) => { this.toggleMobileMenu(event, this.#mobileMenuButton); // toggle submenu on mobile }); this.closeOnBlur(this.#mobileMenuButton, { "navType": "desktop" }); // close submenu when user clicks outside this.closeOnBlur(this.#mobileMenuButton, { "navType": "mobile" }); } /** * Handles dropdowns on desktop navigation * Loops over list of navigation dropdown buttons * and adds eventlisteners to toggle view */ desktopMenuDropdowns() { this.#dropdownButtons.forEach((button) => { button.addEventListener("click", (event) => { this.toggleDropdown(event, button); }); this.closeOnBlur(button); // close menu when user clicks outside this.handleFocus(button); // close dropdown when user finishes tabbing submenu elements }); } /** * Toggles desktop dropdowns * * @param {EventTarget} event * @param {HTMLButtonElement} button */ toggleDropdown(event, button) { this.toggleAriaExpanded(event, button); } /** * Toggles mobile menu * * @param {EventTarget} event * @param {HTMLButtonElement} button */ toggleMobileMenu(event, button) { this.toggleAriaExpanded(event, button) } /** * Toggles aria-expanded attribute * * @param {EventTarget} event * @param {HTMLButtonElement} button */ toggleAriaExpanded(event, button) { let isExpanded = event.currentTarget.getAttribute("aria-expanded") === "true"; // true is returned as string // ... but open the targeted secondary nav if (!isExpanded) { // close all dropdowns... this.closeAllDropDowns(); // then toggle targeted dropdown... button.setAttribute("aria-expanded", true); } else { button.setAttribute("aria-expanded", false); } } /** * Close dropdown when user clicks anywhere else on the screen * * @param {HTMLButtonElement} button * @link {https://developer.mozilla.org/en-US/docs/Web/API/Element/blur_event} * @link {https://developer.mozilla.org/en-US/docs/Web/API/FocusEvent/relatedTarget} */ closeOnBlur(button, args) { if (args === undefined || args?.navType === "desktop") { button.addEventListener("blur", (event) => { if (event.relatedTarget == null) { event.currentTarget.setAttribute("aria-expanded", false); } }, { passive: true }); } //FIXME: Remove hardcoded literal if (args?.navType === "mobile") { document.getElementById("menu-container").addEventListener("blur", (event) => { // Note: cannot group negation -1*-1*-1 != -(1*1*1) const isNull = event.relatedTarget == null; const isNotMenuItem = !isNull && !event.relatedTarget.classList.contains("menu-vdi__toggle") && !event.relatedTarget.classList.contains("sub-menu-item") && !event.relatedTarget.classList.contains("menu-vdi__link"); if (isNull || isNotMenuItem) button.setAttribute("aria-expanded", false); }, true) } } /** * Handles escape behaviour * Closes all dropdown when user hits * escape key * * @link {https://developer.mozilla.org/en-US/docs/Web/API/Element/keyup_event} */ handleEscapeKey() { window.addEventListener("keyup", (event) => { if (event.key === "Escape") { this.#mobileMenuButton.setAttribute("aria-expanded", false); this.closeAllDropDowns(); } }, { passive: true }); } /** * Close all dropdown menus * Sets aria expanded property by looping * over the list of dropdown button elements * * @link {https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-expanded} */ closeAllDropDowns() { this.#dropdownButtons.forEach((button) => { button.setAttribute("aria-expanded", false); }); } /** * Handle focus * Watches blur event on submenu list container, if the next * focus element is not a submenu list item, closes the * dropdown. * * Implemented for WCAG 2.2 compliance * * @param {htmlButtonElement} button */ handleFocus(button) { const subMenuListElement = button.closest("ul"); /** * Ducking JavaScript, I am not sure why the blur event on submenu(ul) * would bubble up to the button since the button and sub menu list element * are siblings and not nested. This is a mystery to me. * * Here we are stopping any bubbling event that may be propagated to the * top level buttons. This includes the bubbling event from sub menu list element. * * If anyone finds a better solution to this or can explain bubbling of focus event * to next sibling, you time and effort would be much appreciated. */ button.addEventListener("focusout", (event) => { event.stopImmediatePropagation(); }) subMenuListElement.addEventListener("focusout", (event) => { // blur event triggers when user clicks outside const nextFocusElement = event.relatedTarget; let isSubMenuElement; if (nextFocusElement !== null) { isSubMenuElement = nextFocusElement.classList.contains(this.#subMenuElementIdentifier); } else { isSubMenuElement = false; // close when user clicks outside } if (!isSubMenuElement) { this.closeAllDropDowns(); } }); } } export default Navigation;