// SPDX-FileCopyrightText: 2025-2026 Alpin Insight Solutions GmbH & Co. KG // SPDX-License-Identifier: AGPL-3.0-only /** * Dropdown menu component for Insight UI. * * Creates a toggleable dropdown menu that closes when clicking outside. * Only one dropdown can be open at a time. * * @example * // HTML structure * * */ export class Dropdown { /** @type {WeakMap} Weak references to prevent multiple initialization */ static instances = new WeakMap(); /** @type {Dropdown|null} Currently open dropdown instance */ static currentOpen = null; /** * Creates a new Dropdown instance. * * @param {HTMLElement} trigger - The trigger button element with data-insight-dropdown attribute */ constructor(trigger) { // If an instance for this element already exists, return it if (Dropdown.instances.has(trigger)) { return Dropdown.instances.get(trigger); } this.trigger = trigger; this.targetId = trigger.getAttribute("data-insight-dropdown"); this.menu = document.getElementById(this.targetId); if (!this.menu) { debugLog("Dropdown menu target not found!") return; } this.menu.classList.add("absolute", "z-50", "mt-2"); // Bind handlers for proper cleanup this.boundToggleClick = this.handleToggleClick.bind(this); this.boundDocumentClick = this.handleDocumentClick.bind(this); this.boundKeyDown = this.handleKeyDown.bind(this); // Track focusable items in the menu this.menuItems = []; this.currentFocusIndex = -1; this.bindEvents(); this.trigger.__insightInstance = this; Dropdown.instances.set(trigger, this); debugLog("New dropdown created: ", this.trigger, this.menu); } /** * Handles click on the dropdown trigger button. * Closes any other open dropdown before toggling this one. * * @param {Event} e - The click event */ handleToggleClick(e) { e.stopPropagation(); if (Dropdown.currentOpen && Dropdown.currentOpen !== this) { Dropdown.currentOpen.hide(); } this.menu.classList.toggle("hidden"); const isOpen = !this.menu.classList.contains("hidden"); this.trigger.setAttribute("aria-expanded", isOpen ? "true" : "false"); Dropdown.currentOpen = isOpen ? this : null; } /** * Handles clicks outside the dropdown to close it. */ handleDocumentClick() { this.hide(); } /** * Handles keyboard navigation within the dropdown. * * @param {KeyboardEvent} e - The keyboard event */ handleKeyDown(e) { // Only handle if dropdown is open if (this.menu.classList.contains("hidden")) return; switch (e.key) { case 'Escape': e.preventDefault(); this.hide(); this.trigger.focus(); break; case 'ArrowDown': e.preventDefault(); this.focusNextItem(); break; case 'ArrowUp': e.preventDefault(); this.focusPreviousItem(); break; case 'Home': e.preventDefault(); this.focusFirstItem(); break; case 'End': e.preventDefault(); this.focusLastItem(); break; } } /** * Gets all focusable menu items. * * @returns {HTMLElement[]} Array of focusable elements */ getMenuItems() { return Array.from(this.menu.querySelectorAll('a, button, [tabindex]:not([tabindex="-1"])')); } /** * Focuses the next menu item. */ focusNextItem() { const items = this.getMenuItems(); if (items.length === 0) return; this.currentFocusIndex = (this.currentFocusIndex + 1) % items.length; items[this.currentFocusIndex].focus(); } /** * Focuses the previous menu item. */ focusPreviousItem() { const items = this.getMenuItems(); if (items.length === 0) return; this.currentFocusIndex = this.currentFocusIndex <= 0 ? items.length - 1 : this.currentFocusIndex - 1; items[this.currentFocusIndex].focus(); } /** * Focuses the first menu item. */ focusFirstItem() { const items = this.getMenuItems(); if (items.length === 0) return; this.currentFocusIndex = 0; items[0].focus(); } /** * Focuses the last menu item. */ focusLastItem() { const items = this.getMenuItems(); if (items.length === 0) return; this.currentFocusIndex = items.length - 1; items[this.currentFocusIndex].focus(); } /** * Binds event listeners to the trigger and document. */ bindEvents() { this.trigger.addEventListener("click", this.boundToggleClick); document.addEventListener("click", this.boundDocumentClick); document.addEventListener("keydown", this.boundKeyDown); } /** * Hides the dropdown menu. */ hide() { if (!this.menu.classList.contains("hidden")) { this.menu.classList.add("hidden"); this.trigger.setAttribute("aria-expanded", "false"); this.currentFocusIndex = -1; if (Dropdown.currentOpen === this) { Dropdown.currentOpen = null; } } } /** * Destroys the dropdown instance and removes all event listeners. * Call this before removing the element from DOM. */ destroy() { debugLog("Destroy dropdown: ", this.trigger, this.menu); this.trigger.removeEventListener("click", this.boundToggleClick); document.removeEventListener("click", this.boundDocumentClick); document.removeEventListener("keydown", this.boundKeyDown); if (Dropdown.currentOpen === this) { Dropdown.currentOpen = null; } Dropdown.instances.delete(this.trigger); delete this.trigger.__insightInstance; this.trigger = null; this.menu = null; } /** * Initializes all dropdown instances on the page. * * @static */ static initAll() { document.querySelectorAll("[data-insight-dropdown]").forEach(openButton => new Dropdown(openButton)); } }