// ============================================================================
// Stylescape | TabManager
// ============================================================================
// WAI-ARIA tabs for the `ss-c-tab` module.
//
// Markup (either way of pairing tabs and panels works):
//
//
//
//
//
//
//
…
//
…
//
//
// The manager fills in the missing roles, ids and ARIA links, shows one panel
// at a time (`hidden` on the others), keeps `aria-selected`, roving
// `tabindex` and `ss-c-tab__item--active` in sync, and supports Arrow keys,
// Home and End. It dispatches `ss:tab-change` on the root.
// ============================================================================
export interface TabManagerOptions {
/** Tab to show first: its panel id or `data-ss-tab` value. */
initial?: string;
/** Arrow keys for a vertical tab list. */
orientation?: "horizontal" | "vertical";
}
const ACTIVE_CLASS = "ss-c-tab__item--active";
let uid = 0;
export class TabManager {
readonly root: HTMLElement;
private readonly tabs: HTMLElement[];
private readonly panels: HTMLElement[];
private readonly orientation: "horizontal" | "vertical";
private readonly onClick = (event: Event): void => {
const tab = (event.target as Element | null)?.closest(
"[data-ss-tab], [role='tab']",
);
if (tab && this.tabs.includes(tab)) this.select(tab);
};
private readonly onKeydown = (event: KeyboardEvent): void => {
const current = this.tabs.indexOf(event.target as HTMLElement);
if (current < 0) return;
const next =
this.orientation === "vertical" ? "ArrowDown" : "ArrowRight";
const prev = this.orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
let index: number | null = null;
if (event.key === next) index = (current + 1) % this.tabs.length;
else if (event.key === prev)
index = (current - 1 + this.tabs.length) % this.tabs.length;
else if (event.key === "Home") index = 0;
else if (event.key === "End") index = this.tabs.length - 1;
if (index === null) return;
event.preventDefault();
this.select(this.tabs[index]);
this.tabs[index].focus();
};
constructor(root: HTMLElement | string, options: TabManagerOptions = {}) {
const el =
typeof root === "string"
? document.querySelector(root)
: root;
if (!el) throw new Error("TabManager: root element not found");
this.root = el;
this.orientation = options.orientation ?? "horizontal";
this.tabs = Array.from(
el.querySelectorAll("[data-ss-tab], [role='tab']"),
);
this.panels = this.tabs.map((tab) => this.panelFor(tab));
const list = this.tabs[0]?.parentElement;
if (list && !list.hasAttribute("role"))
list.setAttribute("role", "tablist");
if (list && this.orientation === "vertical")
list.setAttribute("aria-orientation", "vertical");
this.tabs.forEach((tab, i) => {
const panel = this.panels[i];
tab.setAttribute("role", "tab");
if (!tab.id) tab.id = `ss-tab-${++uid}`;
if (panel) {
if (!panel.id) panel.id = `ss-tab-panel-${++uid}`;
tab.setAttribute("aria-controls", panel.id);
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", tab.id);
if (!panel.hasAttribute("tabindex")) panel.tabIndex = 0;
}
});
el.addEventListener("click", this.onClick);
el.addEventListener("keydown", this.onKeydown);
const initial =
(options.initial && this.find(options.initial)) ||
this.tabs.find(
(t) => t.getAttribute("aria-selected") === "true",
) ||
this.tabs.find((t) => t.classList.contains(ACTIVE_CLASS)) ||
this.tabs[0];
if (initial) this.select(initial, false);
}
/** The selected tab, if any. */
get selected(): HTMLElement | undefined {
return this.tabs.find(
(t) => t.getAttribute("aria-selected") === "true",
);
}
/** Select a tab by element, panel id or `data-ss-tab` value. */
select(target: HTMLElement | string, notify = true): void {
const tab = typeof target === "string" ? this.find(target) : target;
if (!tab) return;
this.tabs.forEach((t, i) => {
const on = t === tab;
t.setAttribute("aria-selected", String(on));
t.tabIndex = on ? 0 : -1;
t.classList.toggle(ACTIVE_CLASS, on);
const panel = this.panels[i];
if (panel) panel.hidden = !on;
});
if (notify) {
this.root.dispatchEvent(
new CustomEvent("ss:tab-change", {
bubbles: true,
detail: {
tab,
panel: this.panels[this.tabs.indexOf(tab)],
},
}),
);
}
}
/** Alias of `select()` (the API of the earlier inline registry handler). */
activate(target: HTMLElement | string): void {
this.select(target);
}
destroy(): void {
this.root.removeEventListener("click", this.onClick);
this.root.removeEventListener("keydown", this.onKeydown);
}
/** Bind every `[data-ss="tabs"]` (or given selector) under `root`. */
static initAll(
root: ParentNode = document,
selector = "[data-ss='tabs']",
): TabManager[] {
return Array.from(root.querySelectorAll(selector)).map(
(el) => new TabManager(el),
);
}
private find(key: string): HTMLElement | undefined {
return this.tabs.find(
(t) =>
t.getAttribute("data-ss-tab") === key ||
t.getAttribute("aria-controls") === key,
);
}
private panelFor(tab: HTMLElement): HTMLElement {
const key = tab.getAttribute("data-ss-tab");
const controls = tab.getAttribute("aria-controls");
const byKey = key
? Array.from(
this.root.querySelectorAll(
"[data-ss-tab-panel]",
),
).find((p) => p.getAttribute("data-ss-tab-panel") === key)
: undefined;
const panel =
byKey ||
(key && document.getElementById(key)) ||
(controls && document.getElementById(controls));
return panel as HTMLElement;
}
}
export default TabManager;