import { CLASS_ACCORDION_BUTTON, CLASS_ACCORDION_ICON, CLASS_HIDE_LG_UP, CLASS_MOBILE, CLASS_MOBILE_OVERLAY, CLASS_NAV_BUTTON, CLASS_NAV_DROPDOWN, CLASS_OVERLAY, } from "./constants"; interface MegamenuConfig { dataToggle?: string; activeClass?: string; removeOnDestroy?: boolean; } const MODAL_OPEN_CLASS = "has-modal"; const LOCKED_SCROLL_TOP_ATTR = "data-lock-scrolltop"; const BRAND_MARK_SELECTOR = "[data-megamenu-brand-mark]"; const defaultConfig: Required = { dataToggle: "data-toggle", activeClass: "is-active", removeOnDestroy: false, }; export default class Megamenu { element!: HTMLElement; config!: Required; toggleButtons!: NodeListOf; dropdowns!: NodeListOf; mobileMenuButtons!: HTMLButtonElement[]; mobilePanels!: HTMLElement[]; mobileCloseButtons!: HTMLButtonElement[]; activeMobilePanel!: HTMLElement | null; activeMobileTarget!: string | null; desktopOverlay!: HTMLButtonElement | null; accordionButtons!: NodeListOf; desktopOverlayClickHandler!: (event: Event) => void; mobileCloseHandler!: (event: Event) => void; focusTrapHandler!: (event: KeyboardEvent) => void; focusTrapElement!: HTMLElement | null; lastFocusedElement!: HTMLElement | null; isKeyboardUser!: boolean; keydownHandler!: (e: KeyboardEvent) => void; mousedownHandler!: () => void; touchstartHandler!: () => void; instanceName!: string; private isModalOpen(): boolean { return document.body.classList.contains(MODAL_OPEN_CLASS); } private getLockedScrollTop(): number | null { const lockedScrollTopRaw = document.body.getAttribute( LOCKED_SCROLL_TOP_ATTR, ); const lockedScrollTop = Number.parseInt(lockedScrollTopRaw || "", 10); return Number.isFinite(lockedScrollTop) ? lockedScrollTop : null; } private getEffectiveScrollY(): number { if (!this.isModalOpen()) { return window.scrollY; } return this.getLockedScrollTop() ?? window.scrollY; } constructor(element: HTMLElement, config: MegamenuConfig = {}) { if ((element as any).ODS_Megamenu) { return (element as any).ODS_Megamenu; } this.element = element; this.config = { ...defaultConfig, ...config }; this.handleToggleClick = this.handleToggleClick.bind(this); this.handleKeyPress = this.handleKeyPress.bind(this); this.handleScroll = this.handleScroll.bind(this); this.handleAccordionClick = this.handleAccordionClick.bind(this); this.handleArrowNavigation = this.handleArrowNavigation.bind(this); this.trapFocus = this.trapFocus.bind(this); this.handleMobileMenuToggle = this.handleMobileMenuToggle.bind(this); this.desktopOverlayClickHandler = () => this.closeAllDropdowns(); this.mobileCloseHandler = (event: Event) => { event.preventDefault(); this.closeMobileMenu(); }; (this.element as any).ODS_Megamenu = this; this.init(); return this; } static getInstance(el: HTMLElement): Megamenu | null { return el && (el as any).ODS_Megamenu ? (el as any).ODS_Megamenu : null; } init() { // Find all nav buttons with aria-controls attribute (dropdown buttons) this.toggleButtons = this.element.querySelectorAll( `.${CLASS_NAV_BUTTON}[aria-controls]`, ); this.dropdowns = this.element.querySelectorAll(`.${CLASS_NAV_DROPDOWN}`); // Resolve an optional instance name so external triggers can target this menu. this.instanceName = this.element.getAttribute("data-megamenu-name") || this.element.id || ""; // Find mobile panel elements. this.mobilePanels = Array.from( this.element.querySelectorAll(`.${CLASS_MOBILE}`), ); this.activeMobilePanel = null; this.activeMobileTarget = null; this.focusTrapElement = null; // Find all mobile toggles both in menu and in document scope. const localToggleButtons = Array.from( this.element.querySelectorAll( `[data-megamenu-mobile-toggle], .${CLASS_HIDE_LG_UP} button`, ), ) as HTMLButtonElement[]; const globalToggleButtons = Array.from( document.querySelectorAll(`[data-megamenu-mobile-toggle]`), ) as HTMLButtonElement[]; this.mobileMenuButtons = Array.from( new Set([...localToggleButtons, ...globalToggleButtons]), ).filter((button) => this.shouldUseMobileTrigger(button)); this.mobileCloseButtons = Array.from( this.element.querySelectorAll( `.${CLASS_MOBILE_OVERLAY}, [data-megamenu-close-button]`, ), ) as HTMLButtonElement[]; this.mobilePanels.forEach((panel) => { panel.setAttribute("aria-hidden", "true"); }); this.syncMobileButtonState(); // Find desktop overlay element this.desktopOverlay = this.element.querySelector( `.${CLASS_OVERLAY}`, ) as HTMLButtonElement; // Find accordion buttons this.accordionButtons = this.element.querySelectorAll( `.${CLASS_ACCORDION_BUTTON}`, ); // Add click event listeners to toggle buttons this.toggleButtons.forEach((button) => { button.addEventListener("click", this.handleToggleClick); }); // Add mobile menu event listeners this.mobileMenuButtons.forEach((button) => { button.addEventListener("click", this.handleMobileMenuToggle); }); this.mobileCloseButtons.forEach((button) => { button.addEventListener("click", this.mobileCloseHandler); }); // Add desktop overlay event listener if (this.desktopOverlay) { this.desktopOverlay.addEventListener( "click", this.desktopOverlayClickHandler, ); } // Add accordion event listeners this.accordionButtons.forEach((button) => { button.addEventListener("click", this.handleAccordionClick); }); // Add scoped event listeners to this element this.element.addEventListener("keydown", this.handleKeyPress); window.addEventListener("scroll", this.handleScroll); // Track keyboard usage for proper focus management this.isKeyboardUser = false; this.keydownHandler = (e) => { if (e.key === "Tab" || e.key === " " || e.key === "Enter") { this.isKeyboardUser = true; } }; this.mousedownHandler = () => { this.isKeyboardUser = false; }; this.touchstartHandler = () => { this.isKeyboardUser = false; }; this.element.addEventListener("keydown", this.keydownHandler); this.element.addEventListener("mousedown", this.mousedownHandler); this.element.addEventListener("touchstart", this.touchstartHandler); // Set initial tab indices for accessibility this.updateTabIndices(); this.handleScroll(); } shouldUseMobileTrigger(button: HTMLButtonElement) { if (this.element.contains(button)) { return true; } const targetName = button.getAttribute("data-megamenu-mobile-for"); if (!targetName) { const allMegamenus = document.querySelectorAll("[data-megamenu]"); return allMegamenus.length === 1 && allMegamenus[0] === this.element; } if (!this.instanceName) { return false; } return targetName === this.instanceName; } getDefaultMobileTarget() { const firstPanel = this.mobilePanels[0]; return firstPanel?.getAttribute("data-megamenu-mobile-panel") || "main"; } getTargetFromElement(control: HTMLElement | null) { return ( control?.getAttribute("data-megamenu-mobile-target") || this.getDefaultMobileTarget() ); } getPanelByTarget(target: string) { const matchingPanel = this.mobilePanels.find( (panel) => panel.getAttribute("data-megamenu-mobile-panel") === target, ); if (matchingPanel) { return matchingPanel; } // Backward compatibility for existing markup with a single mobile panel. if ( target === this.getDefaultMobileTarget() && this.mobilePanels.length === 1 ) { return this.mobilePanels[0]; } return null; } syncMobileButtonState() { this.mobileMenuButtons.forEach((button) => { const target = this.getTargetFromElement(button); const isActive = this.activeMobileTarget === target; button.setAttribute("aria-expanded", isActive ? "true" : "false"); button.classList.toggle(this.config.activeClass, isActive); }); } isInMobilePanel(element: HTMLElement | null) { return Boolean( element && this.mobilePanels.some((panel) => panel.contains(element)), ); } getFocusRestoreTarget() { const lastFocused = this.lastFocusedElement; if ( lastFocused && lastFocused.isConnected && !this.isInMobilePanel(lastFocused) ) { return lastFocused; } const target = this.activeMobileTarget; if (target) { const matchingTrigger = this.mobileMenuButtons.find( (button) => this.getTargetFromElement(button) === target && !this.isInMobilePanel(button), ); if (matchingTrigger) { return matchingTrigger; } } return ( this.mobileMenuButtons.find((button) => !this.isInMobilePanel(button)) || null ); } setMobilePanelState(panel: HTMLElement, isOpen: boolean) { panel.classList.toggle(this.config.activeClass, isOpen); panel.setAttribute("aria-hidden", isOpen ? "false" : "true"); } handleToggleClick(event: Event) { const button = event.currentTarget as HTMLButtonElement; const isActive = button.classList.contains(this.config.activeClass); // Close all other dropdowns this.closeAllDropdowns(); // Toggle current dropdown if (!isActive) { this.openDropdown(button); // Keep focus on the button that opened the dropdown // Users can navigate into the dropdown using arrow keys } else { // If closing, ensure focus remains on the button button.focus(); } } handleKeyPress(event: KeyboardEvent) { if (event.key === "Escape") { // Find the active dropdown button (the one with aria-expanded="true") const activeButton = Array.from(this.toggleButtons).find( (button) => button.getAttribute("aria-expanded") === "true", ); this.closeAllDropdowns(); this.closeMobileMenu(); this.closeAllAccordions(); // Return focus to button that opened the menu if (activeButton) { activeButton.focus(); } } // Handle Enter and Space keys for dropdown buttons and accordion buttons if (event.key === "Enter" || event.key === " ") { const target = event.target as HTMLElement; if ( (target.tagName === "BUTTON" && target.hasAttribute("aria-controls")) || target.classList.contains(CLASS_ACCORDION_BUTTON) ) { event.preventDefault(); target.click(); } } // Handle Home and End keys for navigation within dropdowns if (event.key === "Home" || event.key === "End") { const dropdown = (event.target as HTMLElement)?.closest( `.${CLASS_NAV_DROPDOWN}`, ) as HTMLElement; if (dropdown) { event.preventDefault(); const focusableElements = Array.from( dropdown.querySelectorAll( 'a, button, [tabindex]:not([tabindex="-1"])', ), ) as HTMLElement[]; if (event.key === "Home" && focusableElements.length > 0) { focusableElements[0].focus(); } else if (event.key === "End" && focusableElements.length > 0) { focusableElements[focusableElements.length - 1].focus(); } } } // Arrow key navigation for dropdowns and from buttons to dropdowns if (event.key === "ArrowDown") { const target = event.target as HTMLElement; // If focus is on a dropdown button, move to first item in dropdown if (target.tagName === "BUTTON" && target.hasAttribute("aria-controls")) { const isExpanded = target.getAttribute("aria-expanded") === "true"; if (isExpanded) { event.preventDefault(); const dropdown = target.parentElement?.querySelector( `.${CLASS_NAV_DROPDOWN}`, ) as HTMLElement; const firstFocusable = dropdown?.querySelector( "a, button", ) as HTMLElement; if (firstFocusable) { firstFocusable.focus(); } } } // If focus is within a dropdown, navigate within it else { const dropdown = target.closest( `.${CLASS_NAV_DROPDOWN}`, ) as HTMLElement; if (dropdown) { event.preventDefault(); this.handleArrowNavigation(event, dropdown); } } } if (event.key === "ArrowUp") { const dropdown = (event.target as HTMLElement)?.closest( `.${CLASS_NAV_DROPDOWN}`, ) as HTMLElement; if (dropdown) { event.preventDefault(); this.handleArrowNavigation(event, dropdown); } } } handleAccordionClick(event: Event) { const button = event.currentTarget as HTMLButtonElement; const isExpanded = button.getAttribute("aria-expanded") === "true"; // Close all other accordions first this.closeAllAccordions(); // Toggle current accordion if (!isExpanded) { this.openAccordion(button); } } handleScroll() { const topElement = this.element.querySelector( '[data-hide-when-sticky="true"]', ) as HTMLElement; const hasBrandMark = Boolean( this.element.querySelector(BRAND_MARK_SELECTOR), ); const effectiveScrollY = this.getEffectiveScrollY(); const hideThreshold = topElement?.offsetHeight || 15; const shouldHideTop = effectiveScrollY > hideThreshold; const isTopHidden = this.element.classList.contains("top-hidden"); if (topElement) { if (shouldHideTop && !isTopHidden) { topElement.classList.add("is-hidden"); this.element.classList.add("top-hidden"); } else if (!shouldHideTop && isTopHidden) { topElement.classList.remove("is-hidden"); this.element.classList.remove("top-hidden"); } } else if (!this.element.classList.contains("non-sticky") && hasBrandMark) { this.element.classList.toggle("top-hidden", shouldHideTop); } } openDropdown(button: HTMLButtonElement) { const dropdown = button.parentElement?.querySelector( `.${CLASS_NAV_DROPDOWN}`, ) as HTMLElement; if (dropdown) { button.classList.add(this.config.activeClass); button.setAttribute("aria-expanded", "true"); dropdown.style.display = "block"; dropdown.classList.add("is-visible"); this.showDesktopOverlay(); this.updateTabIndices(); } } closeDropdown(button: HTMLButtonElement) { const dropdown = button.parentElement?.querySelector( `.${CLASS_NAV_DROPDOWN}`, ) as HTMLElement; if (dropdown) { button.classList.remove(this.config.activeClass); button.setAttribute("aria-expanded", "false"); dropdown.classList.remove("is-visible"); dropdown.style.display = "none"; this.updateTabIndices(); } } closeAllDropdowns() { this.toggleButtons.forEach((button) => { this.closeDropdown(button); }); this.hideDesktopOverlay(); } handleMobileMenuToggle(event: Event) { event.preventDefault(); const trigger = event.currentTarget as HTMLButtonElement; const target = this.getTargetFromElement(trigger); const panel = this.getPanelByTarget(target); if (!panel) { return; } if ( this.activeMobileTarget === target && panel.classList.contains(this.config.activeClass) ) { this.closeMobileMenu(); } else { this.openMobileMenu(target, trigger); } } openMobileMenu(target = this.getDefaultMobileTarget(), source?: HTMLElement) { const panel = this.getPanelByTarget(target); if (!panel) { return; } // Store the element that opened the menu for focus restoration // BEFORE changing panel states, so activeElement is captured correctly. this.lastFocusedElement = source || (document.activeElement as HTMLElement | null); // Move focus out of any panel about to be hidden to prevent // the browser from blocking aria-hidden on a focused descendant. const focused = document.activeElement as HTMLElement | null; if ( focused && focused !== panel && this.mobilePanels.some((p) => p !== panel && p.contains(focused)) ) { focused.blur(); } // Keep one side panel active at a time. this.mobilePanels.forEach((mobilePanel) => { this.setMobilePanelState(mobilePanel, mobilePanel === panel); }); this.activeMobilePanel = panel; this.activeMobileTarget = target; this.syncMobileButtonState(); document.body.style.overflow = "hidden"; this.removeFocusTrap(); this.trapFocus(panel); if (this.isKeyboardUser) { const firstFocusable = panel.querySelector("button, a") as HTMLElement; if (firstFocusable) { setTimeout(() => firstFocusable.focus(), 50); } } this.updateTabIndices(); } closeMobileMenu() { if (!this.activeMobilePanel) { return; } // Move focus out of the panel BEFORE setting aria-hidden to prevent // the browser from blocking aria-hidden on a focused descendant. const restoreTarget = this.getFocusRestoreTarget(); if (restoreTarget) { restoreTarget.focus(); } else { (document.activeElement as HTMLElement | null)?.blur?.(); } this.mobilePanels.forEach((panel) => { this.setMobilePanelState(panel, false); }); document.body.style.overflow = ""; this.closeAllAccordions(); this.removeFocusTrap(); this.activeMobilePanel = null; this.activeMobileTarget = null; this.lastFocusedElement = null; this.syncMobileButtonState(); this.updateTabIndices(); } showDesktopOverlay() { if (this.desktopOverlay) { this.desktopOverlay.classList.add(this.config.activeClass); } } hideDesktopOverlay() { if (this.desktopOverlay) { this.desktopOverlay.classList.remove(this.config.activeClass); } } openAccordion(button: HTMLButtonElement) { const accordionId = button.getAttribute("aria-controls"); const accordionBody = accordionId ? (this.element.querySelector(`#${accordionId}`) as HTMLElement) : null; const icon = button.querySelector( `.${CLASS_ACCORDION_ICON}`, ) as HTMLElement; if (accordionBody) { button.setAttribute("aria-expanded", "true"); button.classList.add(this.config.activeClass); accordionBody.classList.add("is-visible"); accordionBody.style.display = "block"; if (icon) { icon.classList.add(this.config.activeClass); } } } closeAccordion(button: HTMLButtonElement) { const accordionId = button.getAttribute("aria-controls"); const accordionBody = accordionId ? (this.element.querySelector(`#${accordionId}`) as HTMLElement) : null; const icon = button.querySelector( `.${CLASS_ACCORDION_ICON}`, ) as HTMLElement; if (accordionBody) { button.setAttribute("aria-expanded", "false"); button.classList.remove(this.config.activeClass); accordionBody.classList.remove("is-visible"); accordionBody.style.display = "none"; if (icon) { icon.classList.remove(this.config.activeClass); } } } closeAllAccordions() { this.accordionButtons.forEach((button) => { this.closeAccordion(button); }); } handleArrowNavigation(event: KeyboardEvent, dropdown: HTMLElement) { const focusableElements = Array.from( dropdown.querySelectorAll('a, button, [tabindex]:not([tabindex="-1"])'), ) as HTMLElement[]; if (focusableElements.length === 0) return; const currentIndex = focusableElements.findIndex( (item) => item === event.target, ); let nextIndex; if (event.key === "ArrowDown") { nextIndex = currentIndex < focusableElements.length - 1 ? currentIndex + 1 : 0; } else if (event.key === "ArrowUp") { nextIndex = currentIndex > 0 ? currentIndex - 1 : focusableElements.length - 1; } else { return; // Don't handle other keys } // Ensure nextIndex is valid if ( nextIndex >= 0 && nextIndex < focusableElements.length && focusableElements[nextIndex] ) { focusableElements[nextIndex].focus(); } } trapFocus(element: HTMLElement) { const focusableElements = element.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', ) as NodeListOf; const firstFocusable = focusableElements[0]; const lastFocusable = focusableElements[focusableElements.length - 1]; this.focusTrapHandler = (e: KeyboardEvent) => { if (e.key === "Tab") { if (e.shiftKey) { if (document.activeElement === firstFocusable) { e.preventDefault(); lastFocusable.focus(); } } else { if (document.activeElement === lastFocusable) { e.preventDefault(); firstFocusable.focus(); } } } }; this.focusTrapElement = element; element.addEventListener("keydown", this.focusTrapHandler); } removeFocusTrap() { if (this.focusTrapElement && this.focusTrapHandler) { this.focusTrapElement.removeEventListener( "keydown", this.focusTrapHandler, ); this.focusTrapElement = null; } } updateTabIndices() { // Set tabindex=-1 for inactive dropdown items this.dropdowns.forEach((dropdown) => { const isVisible = dropdown.classList.contains("is-visible"); const focusableElements = dropdown.querySelectorAll( "a, button, [tabindex]:not([tabindex='-1'])", ) as NodeListOf; focusableElements.forEach((element) => { // Only update tabIndex if element is not already properly set const shouldBeFocusable = isVisible; if (shouldBeFocusable && element.tabIndex === -1) { element.tabIndex = 0; } else if (!shouldBeFocusable && element.tabIndex !== -1) { element.tabIndex = -1; } }); }); // Also update mobile menu elements when mobile menu is active this.mobilePanels.forEach((panel) => { const isMobilePanelActive = panel.classList.contains( this.config.activeClass, ); const mobilePanelFocusableElements = panel.querySelectorAll( "a, button, [tabindex]:not([tabindex='-1'])", ) as NodeListOf; mobilePanelFocusableElements.forEach((element) => { element.tabIndex = isMobilePanelActive ? 0 : -1; }); }); } destroy() { // Remove event listeners this.toggleButtons.forEach((button) => { button.removeEventListener("click", this.handleToggleClick); }); // Remove mobile menu event listeners this.mobileMenuButtons.forEach((button) => { button.removeEventListener("click", this.handleMobileMenuToggle); }); this.mobileCloseButtons.forEach((button) => { button.removeEventListener("click", this.mobileCloseHandler); }); // Remove desktop overlay event listener if (this.desktopOverlay) { this.desktopOverlay.removeEventListener( "click", this.desktopOverlayClickHandler, ); } // Remove accordion event listeners this.accordionButtons.forEach((button) => { button.removeEventListener("click", this.handleAccordionClick); }); this.element.removeEventListener("keydown", this.handleKeyPress); window.removeEventListener("scroll", this.handleScroll); // Remove keyboard tracking event listeners this.element.removeEventListener("keydown", this.keydownHandler); this.element.removeEventListener("mousedown", this.mousedownHandler); this.element.removeEventListener("touchstart", this.touchstartHandler); // Restore body overflow if menu was open document.body.style.overflow = ""; this.removeFocusTrap(); // Clean up instance reference (this.element as any).ODS_Megamenu = null; if (this.config.removeOnDestroy) { this.element.remove(); } } }