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 = [];
}
}