import { uid } from '../shared/dom'; import { TABS_CHANGE_EVENT, type TabsActivationMode, type TabsChangeDetail, type TabsOptions, type TabsOrientation, } from './tabs.types'; interface Tab { value: string; trigger: HTMLElement; panel: HTMLElement; } const SELECTORS = { list: '[data-c42-tabs-list]', trigger: '[data-c42-tabs-trigger]', panel: '[data-c42-tabs-panel]', } as const; /** * Headless tabs controller. Wires ARIA `tablist`/`tab`/`tabpanel`, roving * tabindex, arrow-key navigation and selection to existing markup; it never * applies visual styles. * * Markup: * ```html *
*
* * *
*
Panel A
*
Panel B
*
* ``` */ export class Tabs { private readonly root: HTMLElement; private readonly list: HTMLElement; private readonly orientation: TabsOrientation; private readonly activationMode: TabsActivationMode; private tabs: Tab[] = []; private selected = ''; private cleanups: Array<() => void> = []; constructor(root: HTMLElement, options: TabsOptions = {}) { const list = root.querySelector(SELECTORS.list); if (!list) { throw new Error('[42/tabs] Needs a [data-c42-tabs-list] element.'); } this.root = root; this.list = list; this.orientation = options.orientation ?? 'horizontal'; this.activationMode = options.activationMode ?? 'automatic'; this.collect(); this.init(options.defaultValue ?? null); } private collect(): void { const triggers = Array.from(this.list.querySelectorAll(SELECTORS.trigger)); if (triggers.length === 0) { throw new Error('[42/tabs] Needs at least one [data-c42-tabs-trigger].'); } const panelEls = Array.from(this.root.querySelectorAll(SELECTORS.panel)); this.tabs = triggers.map((trigger, index) => { const value = trigger.dataset.value ?? String(index); const panel = panelEls.find((el) => el.dataset.value === value); if (!panel) { throw new Error(`[42/tabs] No panel found for tab value "${value}".`); } return { value, trigger, panel }; }); } private init(defaultValue: string | null): void { this.list.setAttribute('role', 'tablist'); this.list.setAttribute('aria-orientation', this.orientation); this.root.dataset.orientation = this.orientation; this.tabs.forEach(({ value, trigger, panel }) => { const triggerId = trigger.id || uid('tab'); const panelId = panel.id || uid('tabpanel'); trigger.id = triggerId; panel.id = panelId; trigger.setAttribute('role', 'tab'); trigger.setAttribute('aria-controls', panelId); panel.setAttribute('role', 'tabpanel'); panel.setAttribute('aria-labelledby', triggerId); panel.setAttribute('tabindex', '0'); const onClick = (): void => this.select(value); const onKeydown = (event: KeyboardEvent): void => this.onKeydown(event); trigger.addEventListener('click', onClick); trigger.addEventListener('keydown', onKeydown); this.cleanups.push(() => { trigger.removeEventListener('click', onClick); trigger.removeEventListener('keydown', onKeydown); }); }); const initial = defaultValue && this.tabs.some((tab) => tab.value === defaultValue) ? defaultValue : this.tabs[0]!.value; this.selected = initial; this.render(); } private currentIndex(): number { return this.tabs.findIndex((tab) => tab.value === this.selected); } private onKeydown(event: KeyboardEvent): void { const horizontal = this.orientation === 'horizontal'; const nextKey = horizontal ? 'ArrowRight' : 'ArrowDown'; const prevKey = horizontal ? 'ArrowLeft' : 'ArrowUp'; const focusedIndex = this.tabs.findIndex((tab) => tab.trigger === document.activeElement); const from = focusedIndex >= 0 ? focusedIndex : this.currentIndex(); let next = -1; switch (event.key) { case nextKey: next = (from + 1) % this.tabs.length; break; case prevKey: next = (from - 1 + this.tabs.length) % this.tabs.length; break; case 'Home': next = 0; break; case 'End': next = this.tabs.length - 1; break; case 'Enter': case ' ': { const tab = this.tabs[from]; if (tab) { event.preventDefault(); this.select(tab.value); } return; } default: return; } event.preventDefault(); const target = this.tabs[next]; if (!target) { return; } target.trigger.focus(); if (this.activationMode === 'automatic') { this.select(target.value); } } private render(): void { this.tabs.forEach(({ value, trigger, panel }) => { const isSelected = value === this.selected; const state = isSelected ? 'active' : 'inactive'; trigger.setAttribute('aria-selected', String(isSelected)); trigger.setAttribute('tabindex', isSelected ? '0' : '-1'); trigger.dataset.state = state; panel.dataset.state = state; panel.toggleAttribute('hidden', !isSelected); }); } private emit(): void { const detail: TabsChangeDetail = { value: this.selected }; this.root.dispatchEvent(new CustomEvent(TABS_CHANGE_EVENT, { detail, bubbles: true })); } /** Select a tab by value. No-op if already selected or value is unknown. */ select(value: string): void { if (value === this.selected || !this.tabs.some((tab) => tab.value === value)) { return; } this.selected = value; this.render(); this.emit(); } get value(): string { return this.selected; } /** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */ on(event: string, handler: (event: E) => void): () => void { const listener = handler as EventListener; this.root.addEventListener(event, listener); const off = (): void => this.root.removeEventListener(event, listener); this.cleanups.push(off); return off; } destroy(): void { this.cleanups.forEach((fn) => fn()); this.cleanups = []; } }