import { adopt, define, setState, sheet } from "../internals.ts"; export type Orientation = "horizontal" | "vertical"; const toolbarSheet = sheet(` :host { display: flex; } :host(:state(vertical)) { flex-direction: column; } `); /** * A WAI-ARIA toolbar over its light-DOM controls. The toolbar is one tab stop: arrow keys along its * orientation move between controls, wrapping, and Home/End jump to the ends. It remembers the last * focused control, so tabbing back in returns there. Role and orientation live on ElementInternals. */ export class ToolbarElement extends HTMLElement { readonly #internals = this.attachInternals(); readonly #observer = new MutationObserver(() => this.#sync()); #orientation: Orientation = "horizontal"; #current: HTMLElement | undefined; constructor() { super(); this.#internals.role = "toolbar"; this.#internals.ariaOrientation = this.#orientation; setState(this.#internals, "horizontal", true); const root = this.attachShadow({ mode: "open" }); adopt(root, toolbarSheet); root.append(document.createElement("slot")); this.addEventListener("focusin", (event) => { const item = this.#items().find((el) => el === event.target || el.contains(event.target as Node)); if (item) this.#current = item; this.#sync(); }); this.addEventListener("keydown", (event) => this.#onKeydown(event)); } connectedCallback(): void { this.#observer.observe(this, { childList: true, subtree: true, attributes: true, attributeFilter: ["hidden"] }); this.#sync(); } disconnectedCallback(): void { this.#observer.disconnect(); } get label(): string { return this.#internals.ariaLabel ?? ""; } set label(value: string) { this.#internals.ariaLabel = value; } get orientation(): Orientation { return this.#orientation; } set orientation(value: Orientation) { this.#orientation = value; this.#internals.ariaOrientation = value; setState(this.#internals, "horizontal", value === "horizontal"); setState(this.#internals, "vertical", value === "vertical"); } /** The controls the toolbar moves between: its visible children. */ #items(): HTMLElement[] { return [...this.children].filter((el): el is HTMLElement => el instanceof HTMLElement && !el.hidden); } /** Keeps exactly one control in the tab order: the last focused, or the first. */ #sync() { const items = this.#items(); if (!this.#current || !items.includes(this.#current)) this.#current = items[0]; for (const item of items) item.tabIndex = item === this.#current ? 0 : -1; } #onKeydown(event: KeyboardEvent) { if (event.metaKey || event.ctrlKey || event.altKey) return; const items = this.#items(); const at = items.indexOf(this.#current!); if (at === -1) return; const [back, forward] = this.#orientation === "vertical" ? ["ArrowUp", "ArrowDown"] : ["ArrowLeft", "ArrowRight"]; let next: HTMLElement | undefined; if (event.key === forward) next = items[(at + 1) % items.length]; else if (event.key === back) next = items[(at - 1 + items.length) % items.length]; else if (event.key === "Home") next = items[0]; else if (event.key === "End") next = items.at(-1); if (!next) return; event.preventDefault(); this.#current = next; this.#sync(); next.focus(); } } declare global { interface HTMLElementTagNameMap { "nib-toolbar": ToolbarElement; } } define("nib-toolbar", ToolbarElement);