Something went wrong. Try again.
Monorepo for the nibling.run platform
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353import { 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 `<nib-tablist>`. 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 <nib-icon>: 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<HTMLElement>(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 `<nib-tab>` 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);