import { adopt, define, setState, sheet } from "../internals.ts"; import { icon } from "../icons/element.ts"; /** Fired on a tab when it should become the active one: a click, roving focus move, or Enter/Space. */ export class TabSelectEvent extends Event { constructor(readonly tab: TabElement) { super("tabselect", { bubbles: true }); } } /** Fired on a tab when its close control, middle-click, or Delete key asks for it to close. */ export class TabCloseEvent extends Event { constructor(readonly tab: TabElement) { super("tabclose", { bubbles: true }); } } /** * A tab is intrinsically sized (it must never become a container itself, or its own padding * changes would retrigger its own query), but its `@container nib-tablist` queries resolve to the * nearest ancestor container with that name — the tablist's shadow host, across the shadow * boundary. Breakpoints are literal px: custom properties aren't allowed in container conditions. */ const tabSheet = sheet(` :host { display: inline-flex; align-items: center; gap: var(--nib-space-xs); padding-inline: var(--nib-space-sm); max-width: 100%; min-width: 0; flex: 0 1 auto; border-right: var(--nib-size-border) solid var(--nib-color-border); font: var(--nib-font-ui); color: var(--nib-color-text); cursor: default; } :host(:state(selected)) { background: var(--nib-color-canvas); box-shadow: inset 0 var(--nib-size-border) var(--nib-tab-indicator); } :host(:focus-visible) { outline: var(--nib-size-focus-ring) solid var(--nib-color-focus-ring); outline-offset: calc(-1 * var(--nib-size-focus-ring)); } /* The slot is itself a flex item of :host, with the usual flex default of min-width: auto; without overriding it, it won't actually shrink below its content to let text clip/ellipsize. */ slot { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } [part="close"] { all: unset; display: inline-flex; align-items: center; cursor: default; font-size: var(--nib-font-size-icon); color: var(--nib-color-text-muted); } [part="close"][hidden] { display: none; } @container nib-tablist (max-width: 240px) { :host { padding-inline: var(--nib-space-xs); } [part="close"] { opacity: 0; } :host(:state(selected)) [part="close"], :host(:hover) [part="close"] { opacity: 1; } } @container nib-tablist (max-width: 120px) { :host { max-width: var(--nib-size-tab-min); } } `); /** * One tab of a ``. Role, selected state, and the panel relation live on * `ElementInternals`. Light DOM text is the label; the close control lives in the shadow root so * it never becomes a second interactive control inside the tab's own accessible name. */ export class TabElement extends HTMLElement { readonly #internals = this.attachInternals(); readonly #close: HTMLButtonElement; readonly #observer: MutationObserver; #panel: Element | null = null; constructor() { super(); this.#internals.role = "tab"; this.#internals.ariaSelected = "false"; const root = this.attachShadow({ mode: "open" }); adopt(root, tabSheet); root.append(document.createElement("slot")); const close = (this.#close = document.createElement("button")); close.type = "button"; close.part.add("close"); close.tabIndex = -1; close.hidden = true; // icon(), not : every tab shares this tab's own shadow root's one sprite. close.append(icon(root, "close")); close.addEventListener("click", (event) => { // Stop here so the close click never also bubbles into the tab's own "click" listener below. event.stopPropagation(); this.dispatchEvent(new TabCloseEvent(this)); }); close.addEventListener("auxclick", (event) => event.stopPropagation()); root.append(close); this.addEventListener("click", () => this.dispatchEvent(new TabSelectEvent(this))); this.addEventListener("auxclick", (event) => { if (event.button === 1 && this.closable) this.dispatchEvent(new TabCloseEvent(this)); }); this.#observer = new MutationObserver(() => this.#syncLabel()); } connectedCallback() { this.#syncLabel(); this.#observer.observe(this, { childList: true, characterData: true, subtree: true }); } disconnectedCallback() { this.#observer.disconnect(); } get selected(): boolean { return this.#internals.ariaSelected === "true"; } set selected(value: boolean) { this.#internals.ariaSelected = String(value); setState(this.#internals, "selected", value); } get closable(): boolean { return !this.#close.hidden; } set closable(value: boolean) { this.#close.hidden = !value; } get panel(): Element | null { return this.#panel; } set panel(value: Element | null) { this.#panel = value; this.#internals.ariaControlsElements = value ? [value] : []; } #syncLabel() { // The close button's own label is excluded from this: it lives in the shadow root, not the // light DOM text the host's accessible name is read from. const label = this.textContent?.trim() ?? ""; this.#internals.ariaLabel = label; this.#close.ariaLabel = `Close ${label}`; } } /** A selector for elements the roving-tabindex/focus rules would normally land on. */ const FOCUSABLE = "a[href], button, input, select, textarea, audio[controls], video[controls], iframe, [tabindex]"; function containsTabbable(root: Element): boolean { return Array.from(root.querySelectorAll(FOCUSABLE)).some((el) => el.tabIndex >= 0); } /** * A WAI-ARIA tabpanel, named by its tab through `ariaLabelledByElements`. Gets a tabindex only * when it has no tabbable content of its own, per the APG tabs pattern. */ export class TabPanelElement extends HTMLElement { readonly #internals = this.attachInternals(); readonly #observer: MutationObserver; #tab: Element | null = null; constructor() { super(); this.#internals.role = "tabpanel"; this.#observer = new MutationObserver(() => this.#syncTabIndex()); } connectedCallback() { this.#syncTabIndex(); this.#observer.observe(this, { childList: true, subtree: true }); } disconnectedCallback() { this.#observer.disconnect(); } get tab(): Element | null { return this.#tab; } set tab(value: Element | null) { this.#tab = value; this.#internals.ariaLabelledByElements = value ? [value] : []; } #syncTabIndex() { if (containsTabbable(this)) this.removeAttribute("tabindex"); else this.tabIndex = 0; } } /** Named so each tab's `@container nib-tablist` query reaches across the shadow boundary to this host. */ const tablistSheet = sheet(` :host { container: nib-tablist / inline-size; display: flex; align-items: stretch; min-height: var(--nib-size-tab-bar); overflow-x: auto; background: var(--nib-color-surface); border-bottom: var(--nib-size-border) solid var(--nib-color-border); } `); /** * A WAI-ARIA tablist. Tabs are flat `` children in light DOM, declared by the consumer * rather than drawn from a model, so the list only owns roving tabindex and keyboard handling — * never `selected`, which the consumer decides in response to `tabselect`. */ export class TabListElement extends HTMLElement { readonly #internals = this.attachInternals(); readonly #observer: MutationObserver; #current: TabElement | undefined; constructor() { super(); this.#internals.role = "tablist"; const root = this.attachShadow({ mode: "open" }); adopt(root, tablistSheet); root.append(document.createElement("slot")); this.#observer = new MutationObserver((records) => this.#sync(records)); this.addEventListener("tabselect", this.#onTabSelect as EventListener); this.addEventListener("keydown", this.#onKeydown); } connectedCallback() { this.#sync([]); this.#observer.observe(this, { childList: true }); } disconnectedCallback() { this.#observer.disconnect(); } get label(): string { return this.#internals.ariaLabel ?? ""; } set label(value: string) { this.#internals.ariaLabel = value; } get selected(): TabElement | undefined { return this.#tabs().find((tab) => tab.selected); } #tabs(): TabElement[] { return Array.from(this.children).filter((el): el is TabElement => el instanceof TabElement); } #setCurrent(tab: TabElement, focus: boolean) { for (const t of this.#tabs()) t.tabIndex = t === tab ? 0 : -1; this.#current = tab; if (focus) tab.focus(); } #sync(records: MutationRecord[]) { const tabs = this.#tabs(); const previous = this.#current; if (tabs.length === 0) { this.#current = undefined; return; } if (previous && tabs.includes(previous)) return; // The previously-current tab is gone (or there never was one): recover from the removal // record when possible, so focus lands on the tab that took its place. const record = previous && records.find((r) => Array.from(r.removedNodes).includes(previous)); const next = record?.nextSibling; const prev = record?.previousSibling; const candidate = (next instanceof TabElement && tabs.includes(next) && next) || (prev instanceof TabElement && tabs.includes(prev) && prev) || this.selected || tabs[0]!; this.#setCurrent(candidate, Boolean(record)); } #onTabSelect = (event: TabSelectEvent) => { if (this.#tabs().includes(event.tab)) this.#setCurrent(event.tab, false); }; #move(tabs: TabElement[], index: number) { const tab = tabs[index]; if (!tab) return; this.#setCurrent(tab, true); tab.scrollIntoView({ inline: "nearest" }); tab.dispatchEvent(new TabSelectEvent(tab)); } #onKeydown = (event: KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const tabs = this.#tabs(); // Act on whatever tab actually has focus, not just our bookkeeping: programmatic focus (or a // future redraw) could move it there without going through #setCurrent. const active = document.activeElement; const current = active instanceof TabElement && tabs.includes(active) ? active : this.#current && tabs.includes(this.#current) ? this.#current : tabs[0]; const index = current ? tabs.indexOf(current) : -1; if (index < 0) return; // Bring our bookkeeping in line with reality before acting, so a removal that follows (e.g. // Delete) recovers focus relative to the tab that was actually focused. if (current !== this.#current) this.#setCurrent(current!, false); switch (event.key) { case "ArrowRight": this.#move(tabs, (index + 1) % tabs.length); break; case "ArrowLeft": this.#move(tabs, (index - 1 + tabs.length) % tabs.length); break; case "Home": this.#move(tabs, 0); break; case "End": this.#move(tabs, tabs.length - 1); break; case "Enter": case " ": tabs[index]!.dispatchEvent(new TabSelectEvent(tabs[index]!)); break; case "Delete": { const tab = tabs[index]!; if (tab.closable) tab.dispatchEvent(new TabCloseEvent(tab)); break; } default: return; } event.preventDefault(); }; } declare global { interface HTMLElementTagNameMap { "nib-tablist": TabListElement; "nib-tab": TabElement; "nib-tabpanel": TabPanelElement; } } define("nib-tab", TabElement); define("nib-tablist", TabListElement); define("nib-tabpanel", TabPanelElement);