// SPDX-FileCopyrightText: 2025-2026 Alpin Insight Solutions GmbH & Co. KG
// SPDX-License-Identifier: AGPL-3.0-only
/**
* Unified Tabs component supporting both HTMX and static content modes.
*
* Modes are auto-detected:
* - **HTMX mode**: Tabs have `hx-get` attributes, content loaded via HTMX
* - **Static mode**: Tabs have `data-tab-panel` attributes, panels shown/hidden
*
* Expected structure:
* ```html
*
*
*
*
*
*
*
Content 1
*
Content 2
*
*
* ```
*/
export class Tabs {
/** @type {WeakMap} Weak references to prevent multiple initialization */
static instances = new WeakMap();
/**
* Creates a new Tabs instance.
*
* @param {HTMLElement} element - The tabs container element with data-insight-tabs attribute
*/
constructor(element) {
if (Tabs.instances.has(element)) {
return Tabs.instances.get(element);
}
this.element = element;
this.tablist = element.querySelector('[role="tablist"]');
this.tabs = Array.from(this.tablist.querySelectorAll('[role="tab"]'));
this.panelContainer = element.querySelector('[data-tab-panels]') || element.children[1];
// Auto-detect mode: static tabs have data-tab-panel attributes
this.isStatic = this.tabs.some(tab => tab.hasAttribute('data-tab-panel'));
// Store bound handlers for cleanup
this.boundTabHandlers = [];
this.boundHTMXAfterSwap = null;
this.bindEvents();
this.element.__insightInstance = this;
Tabs.instances.set(element, this);
debugLog("New tabs created (static=" + this.isStatic + "): ", this.element);
}
/**
* Binds click and keyboard event listeners to tabs.
*/
bindEvents() {
this.tabs.forEach((tab, index) => {
const keydownHandler = (e) => this.handleKeyDown(e, index);
const clickHandler = () => this.activateTab(tab);
tab.addEventListener('keydown', keydownHandler);
tab.addEventListener('click', clickHandler);
this.boundTabHandlers.push({ element: tab, keydownHandler, clickHandler });
});
// HTMX focus handler (only for HTMX mode)
if (!this.isStatic) {
const tabContentId = this.panelContainer?.id;
if (tabContentId) {
this.boundHTMXAfterSwap = (event) => {
if (event.detail.target.id === tabContentId) {
this.panelContainer.focus();
}
};
document.body.addEventListener('htmx:afterSwap', this.boundHTMXAfterSwap);
}
}
}
/**
* Handles keyboard navigation between tabs.
*
* @param {KeyboardEvent} e - The keydown event
* @param {number} index - The current tab index
*/
handleKeyDown(e, index) {
let newIndex = null;
const length = this.tabs.length;
switch (e.key) {
case 'ArrowRight':
newIndex = (index + 1) % length;
break;
case 'ArrowLeft':
newIndex = (index - 1 + length) % length;
break;
case 'Home':
newIndex = 0;
break;
case 'End':
newIndex = length - 1;
break;
}
if (newIndex !== null) {
e.preventDefault();
this.tabs[newIndex].focus();
// In static mode, also activate the tab on keyboard navigation
if (this.isStatic) {
this.activateTab(this.tabs[newIndex]);
}
}
}
/**
* Activates a tab and shows its associated panel.
*
* @param {HTMLElement} selectedTab - The tab element to activate
*/
activateTab(selectedTab) {
this.tabs.forEach(tab => {
const isSelected = tab === selectedTab;
// Update ARIA state
tab.setAttribute('aria-selected', isSelected ? 'true' : 'false');
tab.setAttribute('tabindex', isSelected ? '0' : '-1');
// Update styling
if (isSelected) {
tab.classList.remove('border-transparent', 'text-insight-body');
tab.classList.add('border-insight-primary', 'text-insight-primary');
} else {
tab.classList.remove('border-insight-primary', 'text-insight-primary');
tab.classList.add('border-transparent', 'text-insight-body');
}
// Static mode: show/hide panels
if (this.isStatic) {
const panelId = tab.getAttribute('data-tab-panel');
const panel = panelId ? document.getElementById(panelId) : null;
if (panel) {
panel.classList.toggle('hidden', !isSelected);
panel.setAttribute('aria-hidden', !isSelected);
}
}
});
// HTMX mode: update panel aria-label
if (!this.isStatic && this.panelContainer) {
this.panelContainer.setAttribute('aria-label', selectedTab.id);
}
}
/**
* Destroys the tabs instance and removes all event listeners.
* Call this before removing the element from DOM.
*/
destroy() {
debugLog("Destroy tabs: ", this.element);
this.boundTabHandlers.forEach(({ element, keydownHandler, clickHandler }) => {
element.removeEventListener('keydown', keydownHandler);
element.removeEventListener('click', clickHandler);
});
this.boundTabHandlers = [];
if (this.boundHTMXAfterSwap) {
document.body.removeEventListener('htmx:afterSwap', this.boundHTMXAfterSwap);
}
Tabs.instances.delete(this.element);
delete this.element.__insightInstance;
this.element = null;
this.tabs = null;
this.panelContainer = null;
}
/**
* Initializes all tabs instances on the page.
*
* @static
*/
static initAll() {
document.querySelectorAll("[data-insight-tabs]").forEach(el => new Tabs(el));
}
}