// SPDX-FileCopyrightText: 2025-2026 Alpin Insight Solutions GmbH & Co. KG
// SPDX-License-Identifier: AGPL-3.0-only
/**
* Accordion component for Insight UI.
*
* Creates an expandable/collapsible accordion with smooth animations,
* keyboard navigation, URL state management, and optional exclusive mode
* where only one panel can be open at a time.
*
* @example
* // HTML structure
*
*
*
Content 1
*
*/
export class Accordion {
/** @type {WeakMap} Weak references to prevent multiple initialization */
static instances = new WeakMap();
/**
* Creates a new Accordion instance.
*
* @param {HTMLElement} element - The accordion container element with data-insight-accordion attribute
*/
constructor(element) {
// If an instance for this element already exists, return it
if (Accordion.instances.has(element)) {
debugLog("Element already instantiated: ", element);
return Accordion.instances.get(element);
}
this.element = element;
this.buttons = Array.from(element.querySelectorAll("button[aria-controls]"));
this.exclusive = element.getAttribute("data-exclusive") === "true";
// Store bound handlers for cleanup
this.boundButtonHandlers = [];
this.bindEvents();
this.handleInitialOpen();
this.element.__insightInstance = this;
Accordion.instances.set(element, this);
debugLog("New accordion created: ", this.element);
}
/**
* Binds click and keyboard event listeners to accordion buttons.
* Supports Arrow keys, Home, and End for keyboard navigation.
*/
bindEvents() {
this.buttons.forEach((button, index) => {
const panelId = button.getAttribute("aria-controls");
const panel = document.getElementById(panelId);
// Click-Event
const clickHandler = () => {
const isExpanded = button.getAttribute("aria-expanded") === "true";
if (this.exclusive) {
this.buttons.forEach((btn) => {
const pid = btn.getAttribute("aria-controls");
const p = document.getElementById(pid);
if (btn !== button) this.closePanel(btn, p);
});
}
if (isExpanded) {
this.closePanel(button, panel);
} else {
this.openPanel(button, panel);
this.updateURL(panelId);
}
};
// Keyboard navigation
const keydownHandler = (event) => {
let targetIndex = null;
switch (event.key) {
case "ArrowDown":
targetIndex = (index + 1) % this.buttons.length;
break;
case "ArrowUp":
targetIndex = (index - 1 + this.buttons.length) % this.buttons.length;
break;
case "Home":
targetIndex = 0;
break;
case "End":
targetIndex = this.buttons.length - 1;
break;
}
if (targetIndex !== null) {
event.preventDefault();
this.buttons[targetIndex].focus();
}
};
button.addEventListener("click", clickHandler);
button.addEventListener("keydown", keydownHandler);
this.boundButtonHandlers.push({
element: button,
clickHandler,
keydownHandler
});
});
}
/**
* Closes an accordion panel with animation.
*
* @param {HTMLElement} button - The trigger button element
* @param {HTMLElement} panel - The panel element to close
*/
closePanel(button, panel) {
button.setAttribute("aria-expanded", "false");
button.querySelector("div")?.classList.remove("rotate-180");
panel.style.height = panel.scrollHeight + "px";
panel.offsetHeight; // Force reflow
panel.style.transition = "height 0.3s ease, opacity 0.3s ease";
panel.style.height = "0px";
panel.style.opacity = "0";
panel.addEventListener("transitionend", (event) => {
if (event.propertyName === "height") {
panel.style.transition = "";
panel.style.height = "0px";
}
}, { once: true });
}
/**
* Opens an accordion panel with animation.
*
* @param {HTMLElement} button - The trigger button element
* @param {HTMLElement} panel - The panel element to open
* @param {boolean} [scroll=true] - Whether to scroll the panel into view
*/
openPanel(button, panel, scroll = true) {
button.setAttribute("aria-expanded", "true");
button.querySelector("div")?.classList.add("rotate-180");
panel.style.transition = "none";
panel.style.height = "auto";
const height = panel.scrollHeight + "px";
panel.style.height = "0px";
panel.offsetHeight;
panel.style.transition = "height 0.3s ease, opacity 0.3s ease";
panel.style.height = height;
panel.style.opacity = "1";
panel.addEventListener("transitionend", (event) => {
if (event.propertyName === "height") {
panel.style.transition = "";
panel.style.height = "auto";
}
}, { once: true });
}
/**
* Updates the URL with the currently open panel ID.
*
* @param {string} id - The panel ID to store in the URL
*/
updateURL(id) {
const url = new URL(window.location);
url.searchParams.set("open", id);
window.history.replaceState({}, "", url);
}
/**
* Handles initial panel opening based on URL query parameter.
*/
handleInitialOpen() {
const params = new URLSearchParams(window.location.search);
const openId = params.get("open");
if (!openId) return;
const buttonToOpen = this.buttons.find(
(btn) => btn.getAttribute("aria-controls") === openId
);
const panelToOpen = document.getElementById(openId);
if (buttonToOpen && panelToOpen) {
if (this.exclusive) {
this.buttons.forEach((btn) => {
const pid = btn.getAttribute("aria-controls");
const p = document.getElementById(pid);
this.closePanel(btn, p);
});
}
this.openPanel(buttonToOpen, panelToOpen, true);
}
}
/**
* Destroys the accordion instance and removes all event listeners.
* Call this before removing the element from DOM.
*/
destroy() {
debugLog("Destroy accordion: ", this.element);
this.boundButtonHandlers.forEach(({ element, clickHandler, keydownHandler }) => {
element.removeEventListener("click", clickHandler);
element.removeEventListener("keydown", keydownHandler);
});
this.boundButtonHandlers = [];
Accordion.instances.delete(this.element);
delete this.element.__insightInstance;
this.element = null;
}
/**
* Initializes all accordion instances on the page.
*
* @static
*/
static initAll() {
document.querySelectorAll("[data-insight-accordion]").forEach(el => new Accordion(el));
}
}