Command
A command menu for search and quick actions, opened as a popover or a dialog.
A <ui-command> is a cmdk-style palette with the search input inside its panel. The panel is either a popover="auto" dropdown (native light dismiss with a popovertarget trigger) or a <dialog class="ui-dialog"> (modal style with a command="show-modal" trigger). Items rank by match score, the best match auto-highlights, and Enter activates the highlighted item just like a click.
Actions use native platform attributes. Navigation items are real <a href> links, and action items are <button command commandfor> invoker buttons. Opening a dialog or popover requires no custom glue code, and custom --commands dispatch directly to target elements. Every activation dispatches a bubbling zazz:command-select event (detail: { item, value }) from the root. Without JavaScript, the trigger still opens the panel natively and items remain clickable; only live filtering, ranking highlights, and hotkeys are skipped.
Popover
<ui-command data-command-hotkey="mod+k" class="w-full max-w-xl"> <button class="ui-button w-full justify-between" type="button" data-variant="outline" popovertarget="command-example-1" data-slot="command-open" > <span class="text-muted-foreground">Search...</span> <ui-kbd-group><kbd>⌘</kbd><kbd>K</kbd></ui-kbd-group> </button> <div id="command-example-1" data-slot="command-panel" data-side="bottom" data-align="start" popover="auto" > <header data-slot="command-header"> <input data-slot="command-input" type="text" placeholder="Type a command or search..." role="combobox" aria-expanded="false" aria-autocomplete="list" autocomplete="off" aria-label="Search commands" /> </header> <div role="listbox" data-slot="command-list" aria-label="Commands"> <div role="group" data-slot="command-group" aria-labelledby="command-example-1-nav"> <span class="font-strong text-eyebrow text-muted-foreground" data-slot="command-group-label" id="command-example-1-nav" >Navigation</span > <a role="option" data-slot="command-item" class="ui-button justify-start" data-variant="ghost" href="#docs" tabindex="-1" data-keywords="documentation guides" data-hotkey="mod+shift+d" > Go to docs <ui-kbd-group><kbd>⇧</kbd><kbd>⌘</kbd><kbd>D</kbd></ui-kbd-group> </a> <a role="option" data-slot="command-item" class="ui-button justify-start" data-variant="ghost" href="#settings" tabindex="-1" data-keywords="preferences options" data-hotkey="mod+shift+s" > Go to settings <ui-kbd-group><kbd>⇧</kbd><kbd>⌘</kbd><kbd>S</kbd></ui-kbd-group> </a> </div> <div role="group" data-slot="command-group" aria-labelledby="command-example-1-actions"> <span class="font-strong text-eyebrow text-muted-foreground" data-slot="command-group-label" id="command-example-1-actions" >Actions</span > <button role="option" data-slot="command-item" class="ui-button justify-start" data-variant="ghost" type="button" command="show-modal" commandfor="command-example-1-dialog" tabindex="-1" data-keywords="modal open" > Open example dialog </button> </div> </div> <p data-slot="command-empty" class="text-muted-foreground text-sm">No commands found.</p> <footer data-slot="command-footer"> <span ><ui-kbd-group><kbd>↑</kbd><kbd>↓</kbd></ui-kbd-group> navigate</span > <span><kbd>↵</kbd> select</span> <span><kbd>esc</kbd> close</span> </footer> </div></ui-command><dialog id="command-example-1-dialog" class="ui-dialog" closedby="any" data-side="bottom" data-align="center"> <div data-slot="dialog-content"> <header data-slot="dialog-header"> <h2 class="text-lg font-heading font-strong">Opened by a command</h2> </header> <div data-slot="dialog-body"> <p>The command item is a plain invoker button (no custom wiring).</p> </div> </div> <footer data-slot="dialog-footer"> <button class="ui-button" type="button" commandfor="command-example-1-dialog" command="close"> Close </button> </footer></dialog>// base/command-score.js"use strict";/** * @fileoverview Fuzzy command-scoring for the typeahead family. * @description Rates how well a search query matches a candidate string, * returning 0 (no match) to 1 (perfect continuous match). Continuous runs and * word-boundary jumps score high; scattered character jumps, transpositions, * case mismatches, and skipped characters decay the score. Autocomplete, * combobox, and command all rank their items with this single function. * * Vendored from cmdk's `command-score.ts` by @pacocoursey (MIT), itself * adapted from Superhuman's `command-score` (MIT), which builds on Joshaven * Potter's `string_score`. The constants and recursion are kept verbatim so * results match cmdk's ranking; only the module shape is Zazz's. * * @see https://github.com/pacocoursey/cmdk * @see https://github.com/superhuman/command-score */// --- Scoring constants ---// The scores are arranged so that a continuous match of characters will// result in a total score of 1.//// The best case: this character is a match, and either this is the start of// the string or the previous character was also a match.const SCORE_CONTINUE_MATCH = 1;// A new match at the start of a word scores better than a new match// elsewhere, as it's more likely that the user will type the starts of// fragments. Word jumps between spaces score slightly higher than slashes,// brackets, hyphens, etc.const SCORE_SPACE_WORD_JUMP = 0.9;const SCORE_NON_SPACE_WORD_JUMP = 0.8;// Any other match isn't ideal, but is included for completeness.const SCORE_CHARACTER_JUMP = 0.17;// If the user transposed two letters, it should be significantly penalized:// "ouch" is more likely than "curtain" when "uc" is typed.const SCORE_TRANSPOSITION = 0.1;// The goodness of a match decays slightly with each skipped character:// "bad" is more likely than "bard" when "bd" is typed.const PENALTY_SKIPPED = 0.999;// An exact-case match beats a case-insensitive match by a small amount:// "HTML" is more likely than "haml" when "HM" is typed.const PENALTY_CASE_MISMATCH = 0.9999;// If the candidate has more characters than the user typed, penalize// slightly: "html" is more likely than "html5" when "html" is typed.const PENALTY_NOT_COMPLETE = 0.99;const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/;const COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g;const IS_SPACE_REGEXP = /[\s-]/;const COUNT_SPACE_REGEXP = /[\s-]/g;// --- Scoring ---/** * @description Recursive scorer over (candidate index, query index) pairs, * memoized per pair so repeated subproblems resolve in constant time. * * @param target - The candidate string (original casing). * @param query - The query string (original casing). * @param lowerTarget - Pre-lowercased candidate. * @param lowerQuery - Pre-lowercased query. * @param targetIndex - Current position in the candidate. * @param queryIndex - Current position in the query. * @param memo - Shared memoization table for this scoring run. * @returns The best score reachable from this position. * @private */function commandScoreInner(target, query, lowerTarget, lowerQuery, targetIndex, queryIndex, memo) { if (queryIndex === query.length) { if (targetIndex === target.length) { return SCORE_CONTINUE_MATCH; } return PENALTY_NOT_COMPLETE; } const memoKey = `${targetIndex},${queryIndex}`; const memoized = memo[memoKey]; if (memoized !== undefined) { return memoized; } const queryChar = lowerQuery.charAt(queryIndex); let index = lowerTarget.indexOf(queryChar, targetIndex); let highScore = 0; while (index >= 0) { let score = commandScoreInner(target, query, lowerTarget, lowerQuery, index + 1, queryIndex + 1, memo); if (score > highScore) { if (index === targetIndex) { score *= SCORE_CONTINUE_MATCH; } else if (IS_GAP_REGEXP.test(target.charAt(index - 1))) { score *= SCORE_NON_SPACE_WORD_JUMP; const wordBreaks = target.slice(targetIndex, index - 1).match(COUNT_GAPS_REGEXP); if (wordBreaks && targetIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length); } } else if (IS_SPACE_REGEXP.test(target.charAt(index - 1))) { score *= SCORE_SPACE_WORD_JUMP; const spaceBreaks = target.slice(targetIndex, index - 1).match(COUNT_SPACE_REGEXP); if (spaceBreaks && targetIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length); } } else { score *= SCORE_CHARACTER_JUMP; if (targetIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, index - targetIndex); } } if (target.charAt(index) !== query.charAt(queryIndex)) { score *= PENALTY_CASE_MISMATCH; } } if ((score < SCORE_TRANSPOSITION && lowerTarget.charAt(index - 1) === lowerQuery.charAt(queryIndex + 1)) || // Allow duplicate letters (cmdk ref #7428) (lowerQuery.charAt(queryIndex + 1) === lowerQuery.charAt(queryIndex) && lowerTarget.charAt(index - 1) !== lowerQuery.charAt(queryIndex))) { const transposedScore = commandScoreInner(target, query, lowerTarget, lowerQuery, index + 1, queryIndex + 2, memo); if (transposedScore * SCORE_TRANSPOSITION > score) { score = transposedScore * SCORE_TRANSPOSITION; } } if (score > highScore) { highScore = score; } index = lowerTarget.indexOf(queryChar, index + 1); } memo[memoKey] = highScore; return highScore;}/** * @description Lowercases a string and folds every space-like character to a * plain space so variants match each other. * * @param value - The string to normalize. * @returns The normalized string. * @private */function formatInput(value) { return value.toLowerCase().replace(COUNT_SPACE_REGEXP, " ");}/** * @description Scores how well `query` matches `target`, optionally widening * the candidate with alias strings (extra keywords that should also match). * * @param target - The candidate string to score against. * @param query - What the user typed. * @param aliases - Extra match targets appended to the candidate. * @returns 0 (no match) to 1 (perfect continuous match). */function commandScore(target, query, aliases = []) { const haystack = aliases.length > 0 ? `${target} ${aliases.join(" ")}` : target; return commandScoreInner(haystack, query, formatInput(haystack), formatInput(query), 0, 0, {});}export { commandScore };// base/hotkeys.js"use strict";// --- Parsing ---/** * @description Parses a `"mod+shift+p"`-style spec into a structured hotkey. * * @param spec - The hotkey spec, `+`-separated, case-insensitive. * @returns The parsed hotkey, or null for an empty/invalid spec. */function parseHotkey(spec) { const tokens = spec .split("+") .map((token) => token.trim().toLowerCase()) .filter((token) => token.length > 0); if (tokens.length === 0) return null; const hotkey = { key: "", ctrl: false, alt: false, shift: false, meta: false, mod: false, }; for (const token of tokens.slice(0, -1)) { switch (token) { case "mod": hotkey.mod = true; break; case "ctrl": case "control": hotkey.ctrl = true; break; case "alt": case "option": hotkey.alt = true; break; case "shift": hotkey.shift = true; break; case "meta": case "cmd": case "super": hotkey.meta = true; break; default: // Unknown modifier: reject rather than silently matching too much return null; } } hotkey.key = tokens[tokens.length - 1]; return hotkey;}// --- Matching ---/** * @description Whether the current platform treats Meta as the primary * modifier (macOS, iOS, iPadOS). * * @returns True on Apple platforms. * @private */function isApplePlatform() { if (typeof navigator === "undefined") return false; return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent);}/** * @description Matches a keyboard event against a parsed hotkey. Modifier * states must match exactly (`mod+k` does not fire on `mod+shift+k`) except * that `mod` claims whichever of Meta/Control the platform assigns it. * * @param event - The keyboard event to test. * @param hotkey - The parsed hotkey to match. * @param apple - Platform override for tests; defaults to detection. * @returns True when the event is exactly this hotkey. */function matchesHotkey(event, hotkey, apple = isApplePlatform()) { const wantMeta = hotkey.meta || (hotkey.mod && apple); const wantCtrl = hotkey.ctrl || (hotkey.mod && !apple); if (event.metaKey !== wantMeta) return false; if (event.ctrlKey !== wantCtrl) return false; if (event.altKey !== hotkey.alt) return false; if (event.shiftKey !== hotkey.shift) return false; return event.key.toLowerCase() === hotkey.key;}/** * @description Whether a node is an editable context (form field or * contenteditable), where bare-key hotkeys must not fire. * * @param node - The event target to inspect. * @returns True when typing belongs to the node, not to hotkeys. */function isEditableTarget(node) { if (!(node instanceof HTMLElement)) return false; if (node.isContentEditable) return true; return (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement);}/** * @description Whether a hotkey uses no modifier at all (a bare key), which * should be suppressed in editable contexts. * * @param hotkey - The parsed hotkey. * @returns True when no modifier is required. */function isBareKey(hotkey) { return !hotkey.ctrl && !hotkey.alt && !hotkey.meta && !hotkey.mod;}/** * @description Binds a document-level listener for one hotkey spec. Bare-key * specs are suppressed while focus is in an editable context; matches call * `preventDefault()` before the callback. * * @param spec - The hotkey spec, e.g. `"mod+k"`. * @param callback - Runs on each match, receiving the keyboard event. * @param options - `signal` unbinds the listener when aborted. * @returns True when the spec parsed and the listener was bound. */function bindHotkey(spec, callback, options = {}) { const hotkey = parseHotkey(spec); if (!hotkey || typeof document === "undefined") return false; document.addEventListener("keydown", (event) => { if (event.defaultPrevented || event.repeat) return; if (isBareKey(hotkey) && isEditableTarget(event.target)) return; if (!matchesHotkey(event, hotkey)) return; event.preventDefault(); callback(event); }, { signal: options.signal }); return true;}export { parseHotkey, matchesHotkey, isEditableTarget, isBareKey, bindHotkey };// base/typeahead.js"use strict";/** * @fileoverview Shared typeahead engine for autocomplete, combobox, and command. * @description The common runtime behind the filter-as-you-type family: a * query signal fed by the input, score-based ranking of real DOM items * (`commandScore`), active-item keyboard navigation with * `aria-activedescendant` (focus never leaves the input), and, for form * controls whose panel sits outside the input, ownership of a * `popover="manual"` panel with outside-pointerdown and Escape close paths. * * Subclasses (`UiAutocomplete`, `UiCombobox`, `UiCommand`) declare their slot * prefix and commit semantics; everything else lives here. Filtering only * writes `hidden` and (when ranking) inline `order`: the DOM is never * restructured, so forms, focus, and progressive enhancement stay untouched. * Groups and empty states hide with CSS `:has()`, not code. * * Item facts come from the markup: the match/commit text is `data-value` * (falling back to text content) and `data-keywords` adds extra match * targets. `data-sort="score"` on the root re-ranks visually via `order`. */import { commandScore } from "./command-score.js";import { ZazzElement } from "./zazz-element.js";import { effect, state } from "./signals.js";/** * @description Scores every item against the query. An empty query leaves all * items visible with a neutral score. * * @param query - What the user typed. * @param items - Facts for each item, in DOM order. * @returns One verdict per item, same order as the input. */function rankItems(query, items) { const trimmed = query.trim(); return items.map((item, index) => { const score = trimmed === "" ? 1 : commandScore(item.value, trimmed, item.keywords); return { index, score, hidden: score <= 0 }; });}/** * @description Reducer for active-item keyboard navigation. ArrowDown from * nothing highlights the first item, ArrowUp from nothing the last; both wrap. * * @param current - The currently active index, -1 for none. * @param key - The `KeyboardEvent.key` pressed. * @param count - How many items are visible. * @returns The next active index, -1 when there is nothing to highlight. */function nextActiveIndex(current, key, count) { if (count === 0) return -1; switch (key) { case "ArrowDown": return current < 0 ? 0 : (current + 1) % count; case "ArrowUp": return current < 0 ? count - 1 : (current - 1 + count) % count; case "Home": return 0; case "End": return count - 1; default: return current; }}// --- Element base ---let typeaheadIdCounter = 0;/** * @description Base class for the typeahead family. Subclasses set the slot * prefix and commit behavior; the base owns query state, ranking, keyboard * navigation, ARIA wiring, and (when `managesPanel`) the manual popover. */class TypeaheadElement extends ZazzElement { /** Whether this element opens/closes its own `popover="manual"` panel. */ managesPanel = true; /** * Whether focusing the input opens the panel. Combobox opts out: its input * holds a committed display value, so tabbing through a form must not pop * the list open, any more than tabbing to a select opens its picker. */ openOnFocus = true; /** Whether ranking also re-orders visually via inline `order`. */ get sortByScore() { return this.getAttribute("data-sort") === "score"; } /** Whether filtering auto-highlights the best item (command palettes do). */ autoHighlight = false; searchInput = null; panel = null; query = state(""); open = state(false); activeIndex = state(-1); setup(signal) { const prefix = this.slotPrefix; const panel = this.querySelector(`[data-slot~="${prefix}-panel"]`); const input = this.querySelector('input[role="combobox"]') ?? this.querySelector(`[data-slot~="${prefix}-input"]`); if (!(panel instanceof HTMLElement) || !(input instanceof HTMLInputElement)) return; this.panel = panel; this.searchInput = input; const list = panel.querySelector(`[data-slot~="${prefix}-list"]`); if (list instanceof HTMLElement) { list.id ||= `ui-${prefix}-list-${++typeaheadIdCounter}`; input.setAttribute("aria-controls", list.id); } // Input adapters: DOM events only write signals input.addEventListener("input", () => { this.query.set(input.value); this.activeIndex.set(this.autoHighlight ? 0 : -1); if (this.managesPanel && !this.#inlinePanel()) { this.open.set(input.value.length >= this.#minLength()); } }, { signal }); input.addEventListener("keydown", (event) => this.#onKeydown(event), { signal }); if (this.#inlinePanel()) { // Inline variant: a panel without [popover] renders in flow and is // always open; there is nothing to show, hide, or light-dismiss this.open.set(true); input.setAttribute("aria-expanded", "true"); } else if (this.managesPanel) { if (this.openOnFocus) { input.addEventListener("focus", () => { if (input.value.length >= this.#minLength()) this.open.set(true); }, { signal }); } // Outside pointerdown closes; inside the panel it must not steal focus document.addEventListener("pointerdown", (event) => { if (!(event.target instanceof Node)) return; if (this.contains(event.target)) return; this.open.set(false); }, { signal }); this.addEventListener("focusout", (event) => { const next = event.relatedTarget; if (next instanceof Node && this.contains(next)) return; this.open.set(false); }, { signal }); } else if (panel instanceof HTMLDialogElement) { // Native <dialog> surface: mirror the dialog-lifecycle events this.addEventListener("zazz:dialog-open", (event) => { if (event.target !== panel) return; this.open.set(true); input.focus(); }, { signal }); this.addEventListener("zazz:dialog-close", (event) => { if (event.target !== panel) return; this.open.set(false); }, { signal }); } else { // Native popover="auto" surface owns open/close; mirror it panel.addEventListener("toggle", (event) => { const opened = event.newState === "open"; this.open.set(opened); if (opened) input.focus(); }, { signal }); } // Keep focus in the input. List rows are not focusable, so a mousedown on // one blurs the input, and the resulting focusout closes the panel (and, in // combobox, reverts the query) *before* the click that commits, and the // commit's own input.focus() then re-fires the focus handler that reopens // it. mousedown, not pointerdown: preventing pointerdown would also cancel // touch panning inside the scrollable panel. Scoped to the list rather than // the whole panel so native scrollbar dragging and header text selection // survive. Suppressing focus transfer does not suppress the click, so link // and invoker items still activate. panel.addEventListener("mousedown", (event) => { if (!(event.target instanceof Element)) return; if (event.target.closest("input, textarea, select, [contenteditable]")) return; if (!event.target.closest(`[data-slot~="${prefix}-list"]`)) return; event.preventDefault(); }, { signal }); // Item click = commit (the mousedown guard above keeps focus in the input, // so no focusout races the click) panel.addEventListener("click", (event) => { if (!(event.target instanceof Element)) return; const item = event.target.closest(`[data-slot~="${prefix}-item"]`); if (item && !item.hidden) this.commit(item, "pointer"); }, { signal }); // Output adapter 1: panel visibility and expanded state (the inline // variant is unconditionally open, so it binds no visibility effect) if (this.#inlinePanel()) { // Nothing to drive } else if (this.managesPanel) { effect(() => { const opened = this.open.get(); input.setAttribute("aria-expanded", String(opened)); if (opened && !this.#panelOpen()) panel.showPopover(); else if (!opened && this.#panelOpen()) panel.hidePopover(); if (!opened) this.activeIndex.set(-1); }, { signal }); } else { effect(() => { input.setAttribute("aria-expanded", String(this.open.get())); }, { signal }); } // Output adapter 2: ranking, visibility, highlight, activedescendant. // All three signals are read up front so every run tracks the same set; // `open` both gates and subscribes. effect(() => { const open = this.open.get(); const query = this.query.get(); const active = this.activeIndex.get(); // Re-filtering the list while the popover is still fading out is the // visible "flash on close": committing or reverting clears the query, // and the rows collapse or re-expand mid-transition. While a // self-managed panel is closed nothing is written: the rows keep their // last filter state and highlight through the exit transition, and // reopening re-ranks in the same microtask drain as showPopover(), so // both land before one paint. The inline variant has no panel to fade, // and a panel owned by a native surface (command) can become visible // before its `toggle` task mirrors `open`, so neither is gated. if (!open && this.managesPanel && !this.#inlinePanel()) { input.removeAttribute("aria-activedescendant"); return; } const { items, ranked, visible } = this.#rank(query); const sort = this.sortByScore; for (const verdict of ranked) { const item = items[verdict.index]; item.hidden = verdict.hidden; if (sort) item.style.order = String(-Math.round(verdict.score * 1000)); else if (item.style.order) item.style.removeProperty("order"); } visible.forEach((item, index) => { item.id ||= `ui-${prefix}-item-${++typeaheadIdCounter}`; if (index === active) item.setAttribute("data-highlighted", ""); else item.removeAttribute("data-highlighted"); }); const highlighted = active >= 0 ? visible[active] : undefined; if (highlighted) { input.setAttribute("aria-activedescendant", highlighted.id); highlighted.scrollIntoView({ block: "nearest" }); } else { input.removeAttribute("aria-activedescendant"); } }, { signal }); } /** * @description The panel's items, in DOM order. * * @returns Every `<prefix>-item` element in the panel. */ items() { const panel = this.panel; if (!panel) return []; return Array.from(panel.querySelectorAll(`[data-slot~="${this.slotPrefix}-item"]`)).filter((node) => node instanceof HTMLElement); } /** * @description The visible items in visual order: score order when * `data-sort="score"`, DOM order otherwise, so arrow keys always follow * what the user sees. * * @param items - All items, DOM order. * @param ranked - The ranker's verdicts for those items. * @returns Visible items in visual order. */ visibleItems(items, ranked) { const visible = ranked.filter((verdict) => !verdict.hidden); if (this.sortByScore) visible.sort((a, b) => b.score - a.score || a.index - b.index); return visible.map((verdict) => items[verdict.index]); } /** * @description Ranks every item against a query without touching the DOM. * Keyboard navigation must not read `item.hidden`: the output effect above * deliberately leaves those flags stale while the panel is closed, so a fresh * ranking is the only trustworthy filter state. * * @param query - What the user typed. * @returns The items in DOM order, their verdicts, and the visible subset in * visual order. * @private */ #rank(query) { const items = this.items(); const ranked = rankItems(query, items.map((item) => ({ value: this.itemValue(item), keywords: (item.getAttribute("data-keywords") ?? "").split(/\s+/).filter(Boolean), }))); return { items, ranked, visible: this.visibleItems(items, ranked) }; } /** * @description The text an item matches and commits with. The text-content * fallback excludes `<kbd>` shortcut hints, which are presentation, not * value ("Go to docs ⇧⌘D" matches and announces as "Go to docs"). * * @param item - The item element. * @returns `data-value` when present, trimmed hint-free text otherwise. */ itemValue(item) { const explicit = item.getAttribute("data-value"); if (explicit !== null) return explicit; if (!item.querySelector("kbd, ui-kbd-group")) return item.textContent?.trim() ?? ""; const clone = item.cloneNode(true); for (const hint of clone.querySelectorAll("kbd, ui-kbd-group")) hint.remove(); return clone.textContent?.trim() ?? ""; } /** * @description Escape with the panel already closed. Autocomplete and command * empty the field; combobox overrides this, because its input carries a * committed display value rather than the filter. */ clearQuery() { const input = this.searchInput; if (!input) return; input.value = ""; this.query.set(""); } /** * @description Minimum query length before the panel opens (`data-min-length`). * * @returns The threshold, 0 by default. * @private */ #minLength() { const raw = Number(this.getAttribute("data-min-length")); return Number.isFinite(raw) && raw > 0 ? raw : 0; } /** * @description Whether the panel is the inline variant: rendered in flow * without `[popover]`, and therefore always open. * * @returns True for an inline panel. * @private */ #inlinePanel() { return this.managesPanel && this.panel !== null && !this.panel.hasAttribute("popover"); } /** * @description Whether the panel popover is currently shown (polyfill-aware). * * @returns True when open. * @private */ #panelOpen() { return this.panel?.matches(":popover-open, .\\:popover-open") ?? false; } /** * @description Keyboard contract on the search input: arrows/Home/End move * the highlight, Enter commits it, Escape closes then clears (only when the * element manages its own panel: native surfaces own Escape themselves). * * @param event - The keydown event. * @private */ #onKeydown(event) { const input = this.searchInput; if (!input) return; if (event.key === "Escape" && this.managesPanel) { // The inline variant has no panel to close: Escape only clears if (this.open.get() && !this.#inlinePanel()) { event.preventDefault(); this.open.set(false); } else if (input.value) { event.preventDefault(); this.clearQuery(); } return; } if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { // Home/End belong to the text caret unless an item is already active if ((event.key === "Home" || event.key === "End") && this.activeIndex.get() < 0) return; event.preventDefault(); if (this.managesPanel && !this.open.get()) this.open.set(true); const count = this.#rank(this.query.get()).visible.length; this.activeIndex.set(nextActiveIndex(this.activeIndex.get(), event.key, count)); return; } if (event.key === "Enter") { const active = this.activeIndex.get(); const item = active >= 0 ? this.#rank(this.query.get()).visible[active] : undefined; if (item) { event.preventDefault(); this.commit(item, "keyboard"); } } }}export { TypeaheadElement, rankItems, nextActiveIndex };// primitives/command/command.js"use strict";/** * @fileoverview `<ui-command>`: a command menu for search and quick actions. * @description Light-DOM custom element on the shared typeahead engine * (`base/typeahead.ts`), architected after cmdk. The panel (a `popover="auto"` * dropdown or a `<dialog class="ui-dialog">`) contains the search input; * items rank by match score and the best match auto-highlights. * * Actions are the platform's own vocabulary: navigation items are real * `<a href>` links, and action items are `<button command commandfor>` invoker * buttons (including custom `--commands`). Enter activates the highlighted * item exactly as a click would; every activation also dispatches a bubbling * `zazz:command-select` CustomEvent (`detail: { item, value }`) from the root. * Without JavaScript the trigger still opens the panel natively and every * item still works: only filtering, highlight, and hotkeys are lost. * * Attributes: * - `data-command-hotkey` (root): global toggle shortcut, e.g. `"mod+k"`. * - `data-hotkey` (item): global accelerator that activates the item, active * while the element is connected (even with the panel closed). * - `data-stay-open` (item): keep the panel open after activation. * - `data-sort="document"` (root): opt out of score ranking (default: score). * * Parts: `command-open` (trigger), `command-panel`, `command-header`, * `command-input`, `command-list`, `command-group` / `command-group-label`, * `command-item` (`data-value`, `data-keywords`), `command-kbd`, * `command-footer`, `command-empty`. * * For complex custom actions, see the example `command-actions.ts`: listen * for your `--command` on its target, or for `zazz:command-select` on the root. */import { TypeaheadElement } from "../../base/typeahead.js";import { bindHotkey } from "../../base/hotkeys.js";import { defineZazzElement } from "../../base/zazz-element.js";class UiCommand extends TypeaheadElement { slotPrefix = "command"; managesPanel = false; autoHighlight = true; /** Command ranks by score unless the author opts back into DOM order. */ get sortByScore() { return this.getAttribute("data-sort") !== "document"; } setup(signal) { super.setup(signal); const input = this.searchInput; const panel = this.panel; if (!input || !panel) return; // Global toggle shortcut on the root const toggleSpec = this.getAttribute("data-command-hotkey"); if (toggleSpec) { bindHotkey(toggleSpec, () => this.#togglePanel(), { signal }); } // Per-item accelerators: global while connected, panel open or not for (const item of this.items()) { const spec = item.getAttribute("data-hotkey"); if (spec) { bindHotkey(spec, () => this.#activate(item, true), { signal }); } } // Reset the search whenever the panel closes, so it reopens fresh const reset = () => { input.value = ""; this.query.set(""); this.activeIndex.set(0); }; if (panel instanceof HTMLDialogElement) { this.addEventListener("zazz:dialog-close", (event) => { if (event.target === panel) reset(); }, { signal }); } else { panel.addEventListener("toggle", (event) => { if (event.newState === "closed") reset(); }, { signal }); } } /** * @description A pointer commit has already run the item's native activation * (link navigation, invoker command); a keyboard commit runs it via * `click()`. Both announce `zazz:command-select` and close the panel unless * the item asks to stay open. * * @param item - The activated item. * @param source - How the commit happened. */ commit(item, source) { this.#activate(item, source === "keyboard"); } /** * @description Runs one item's activation: optional synthetic click, * `zazz:command-select`, then close (unless `data-stay-open`). * * @param item - The item to activate. * @param click - Whether to run the native activation via `click()`. * @private */ #activate(item, click) { this.dispatchEvent(new CustomEvent("zazz:command-select", { bubbles: true, detail: { item, value: this.itemValue(item) }, })); if (click) item.click(); if (!item.hasAttribute("data-stay-open")) this.#closePanel(); } /** * @description Opens or closes the panel, branching on its surface. * @private */ #togglePanel() { const panel = this.panel; if (!panel) return; if (panel instanceof HTMLDialogElement) { if (panel.open) panel.close(); else panel.showModal(); } else { panel.togglePopover(); } } /** * @description Closes the panel, branching on its surface. * @private */ #closePanel() { const panel = this.panel; if (!panel) return; if (panel instanceof HTMLDialogElement) { if (panel.open) panel.close(); } else if (panel.matches(":popover-open, .\\:popover-open")) { panel.hidePopover(); } }}defineZazzElement("ui-command", UiCommand);export { UiCommand };/** * command.css: Command (ui-command | .ui-command, [data-slot~="command-panel"]) * * @layer variables, components * @requires layers.css, _variables.css, popover.css, button.css, fields.css, * dialog.css, menu.css, select.css (--ui-option-*), kbd.css (--ui-kbd-group-*) * @uses popovertarget + [popover="auto"]: dropdown surface, native light dismiss * @uses <dialog class="ui-dialog"> + command="show-modal": focused surface * @uses anchor-name, anchor-scope, position-anchor: tether the popover form * @uses :has(): group + empty-state visibility without JavaScript * @tokens --ui-command-* (@layer variables); items alias --ui-option-* * @see command.ts: ranking, hotkeys, and activation behavior */@layer variables { :root { --ui-command-min-inline-size: var(--step-96); --ui-command-max-block-size: var(--step-96); --ui-command-option-gap: var(--ui-field-option-gap); --ui-command-input-padding: var(--step-3); --ui-command-input-border: 1px solid var(--border); --ui-command-group-label-padding: var(--step-2) var(--step-2) var(--step-1); --ui-command-footer-padding: var(--step-2); --ui-command-footer-border: 1px solid var(--border); --ui-command-shadow: var(--ui-menu-shadow); }}@layer zazz.components { /* =========================================================================== COMMAND: palette panel (popover or dialog) with the input inside - The popover form rides popover.css's surface + placement; the dialog form rides dialog.css. This file shapes the interior: borderless input over a hairline, scrollable list, eyebrow group labels, hint footer. =========================================================================== */ :where(ui-command, .ui-command) { anchor-scope: --ui-command-trigger; display: inline-flex; } :where(ui-command, .ui-command) > :where([popovertarget], [command]) { anchor-name: --ui-command-trigger; } [data-slot~="command-panel"] { /* flex-direction stays ungated so the close fade (display held by the allow-discrete transition) keeps the column layout to the last frame */ flex-direction: column; min-inline-size: var(--ui-command-min-inline-size); box-shadow: var(--ui-command-shadow); } /* Only display is gated; unconditional it would defeat the UA's display: none on closed popovers and dialogs */ [data-slot~="command-panel"]:where(:popover-open, .\:popover-open, [open]) { display: flex; } [data-slot~="command-panel"]:where([popover]) { position-anchor: --ui-command-trigger; padding: 0; } /* Dialog form: the palette owns its interior, edge to edge */ [data-slot~="command-panel"]:where(dialog) { --ui-dialog-width: min(var(--breakpoint-sm), 100% - var(--gutters) * 2); padding: 0; } /* Search input: borderless over a hairline; the panel is the surface */ [data-slot~="command-header"] { display: flex; align-items: center; border-block-end: var(--ui-command-input-border); } [data-slot~="command-input"] { flex: 1; min-inline-size: 0; padding: var(--ui-command-input-padding); font: inherit; font-size: var(--ui-field-font-size); color: inherit; background: none; border: none; outline: none; } [data-slot~="command-list"] { display: flex; flex-direction: column; gap: var(--ui-command-option-gap); max-block-size: var(--ui-command-max-block-size); padding: var(--ui-popover-padding); margin: 0; overflow: auto; list-style: none; } [data-slot~="command-group"] { display: flex; flex-direction: column; gap: var(--ui-command-option-gap); } [data-slot~="command-group-label"] { padding: var(--ui-command-group-label-padding); /* Score ranking writes order -1000..0 on sibling items; keep the label first */ order: -1001; } [data-slot~="command-item"] { cursor: pointer; } [data-slot~="command-item"][data-highlighted] { --ui-button-background: var(--ui-button-background--hover); --ui-button-foreground: var(--ui-button-foreground--hover); } /* Shortcut hint: a lone <kbd> or a <ui-kbd-group> run pushes to the end; spacing between keys inside a run is the group's --ui-kbd-group-gap. */ [data-slot~="command-item"] > :where(kbd, ui-kbd-group, .ui-kbd-group) { margin-inline-start: auto; } [data-slot~="command-footer"] { display: flex; align-items: center; gap: var(--step-2); padding: var(--ui-command-footer-padding); border-block-start: var(--ui-command-footer-border); color: var(--muted-foreground); font-size: var(--font-size-xs); } [data-slot~="command-group"]:not(:has([data-slot~="command-item"]:not([hidden]))) { display: none; } [data-slot~="command-empty"] { display: none; padding: var(--step-2); } [data-slot~="command-panel"]:not(:has([data-slot~="command-item"]:not([hidden]))) [data-slot~="command-empty"] { display: block; }}Dialog
<ui-command data-command-hotkey="mod+shift+k" class="w-full max-w-xl"> <button class="ui-button w-full justify-between" type="button" data-variant="outline" command="show-modal" commandfor="command-example-dialog-1" data-slot="command-open" > <span class="text-muted-foreground">Command palette...</span> <ui-kbd-group><kbd>⌘</kbd><kbd>⇧</kbd><kbd>K</kbd></ui-kbd-group> </button> <dialog id="command-example-dialog-1" class="ui-dialog" data-slot="command-panel" closedby="any"> <header data-slot="command-header"> <input data-slot="command-input" type="text" placeholder="Type a command or search..." role="combobox" aria-expanded="false" aria-autocomplete="list" autocomplete="off" aria-label="Search commands" /> </header> <div role="listbox" data-slot="command-list" aria-label="Commands"> <div role="group" data-slot="command-group" aria-labelledby="command-example-dialog-1-nav"> <span class="font-strong text-eyebrow text-muted-foreground" data-slot="command-group-label" id="command-example-dialog-1-nav" >Navigation</span > <a role="option" data-slot="command-item" class="ui-button justify-start" data-variant="ghost" href="#home" tabindex="-1" > Go home </a> <a role="option" data-slot="command-item" class="ui-button justify-start" data-variant="ghost" href="#projects" tabindex="-1" data-keywords="work portfolio" > Go to projects </a> <a role="option" data-slot="command-item" class="ui-button justify-start" data-variant="ghost" href="#team" tabindex="-1" data-keywords="people members" > Go to team </a> </div> </div> <p data-slot="command-empty" class="text-muted-foreground">No commands found.</p> <footer data-slot="command-footer"> <span ><ui-kbd-group><kbd>↑</kbd><kbd>↓</kbd></ui-kbd-group> navigate</span > <span><kbd>↵</kbd> select</span> <span><kbd>esc</kbd> close</span> </footer> </dialog></ui-command>// base/command-score.js"use strict";/** * @fileoverview Fuzzy command-scoring for the typeahead family. * @description Rates how well a search query matches a candidate string, * returning 0 (no match) to 1 (perfect continuous match). Continuous runs and * word-boundary jumps score high; scattered character jumps, transpositions, * case mismatches, and skipped characters decay the score. Autocomplete, * combobox, and command all rank their items with this single function. * * Vendored from cmdk's `command-score.ts` by @pacocoursey (MIT), itself * adapted from Superhuman's `command-score` (MIT), which builds on Joshaven * Potter's `string_score`. The constants and recursion are kept verbatim so * results match cmdk's ranking; only the module shape is Zazz's. * * @see https://github.com/pacocoursey/cmdk * @see https://github.com/superhuman/command-score */// --- Scoring constants ---// The scores are arranged so that a continuous match of characters will// result in a total score of 1.//// The best case: this character is a match, and either this is the start of// the string or the previous character was also a match.const SCORE_CONTINUE_MATCH = 1;// A new match at the start of a word scores better than a new match// elsewhere, as it's more likely that the user will type the starts of// fragments. Word jumps between spaces score slightly higher than slashes,// brackets, hyphens, etc.const SCORE_SPACE_WORD_JUMP = 0.9;const SCORE_NON_SPACE_WORD_JUMP = 0.8;// Any other match isn't ideal, but is included for completeness.const SCORE_CHARACTER_JUMP = 0.17;// If the user transposed two letters, it should be significantly penalized:// "ouch" is more likely than "curtain" when "uc" is typed.const SCORE_TRANSPOSITION = 0.1;// The goodness of a match decays slightly with each skipped character:// "bad" is more likely than "bard" when "bd" is typed.const PENALTY_SKIPPED = 0.999;// An exact-case match beats a case-insensitive match by a small amount:// "HTML" is more likely than "haml" when "HM" is typed.const PENALTY_CASE_MISMATCH = 0.9999;// If the candidate has more characters than the user typed, penalize// slightly: "html" is more likely than "html5" when "html" is typed.const PENALTY_NOT_COMPLETE = 0.99;const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/;const COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g;const IS_SPACE_REGEXP = /[\s-]/;const COUNT_SPACE_REGEXP = /[\s-]/g;// --- Scoring ---/** * @description Recursive scorer over (candidate index, query index) pairs, * memoized per pair so repeated subproblems resolve in constant time. * * @param target - The candidate string (original casing). * @param query - The query string (original casing). * @param lowerTarget - Pre-lowercased candidate. * @param lowerQuery - Pre-lowercased query. * @param targetIndex - Current position in the candidate. * @param queryIndex - Current position in the query. * @param memo - Shared memoization table for this scoring run. * @returns The best score reachable from this position. * @private */function commandScoreInner(target, query, lowerTarget, lowerQuery, targetIndex, queryIndex, memo) { if (queryIndex === query.length) { if (targetIndex === target.length) { return SCORE_CONTINUE_MATCH; } return PENALTY_NOT_COMPLETE; } const memoKey = `${targetIndex},${queryIndex}`; const memoized = memo[memoKey]; if (memoized !== undefined) { return memoized; } const queryChar = lowerQuery.charAt(queryIndex); let index = lowerTarget.indexOf(queryChar, targetIndex); let highScore = 0; while (index >= 0) { let score = commandScoreInner(target, query, lowerTarget, lowerQuery, index + 1, queryIndex + 1, memo); if (score > highScore) { if (index === targetIndex) { score *= SCORE_CONTINUE_MATCH; } else if (IS_GAP_REGEXP.test(target.charAt(index - 1))) { score *= SCORE_NON_SPACE_WORD_JUMP; const wordBreaks = target.slice(targetIndex, index - 1).match(COUNT_GAPS_REGEXP); if (wordBreaks && targetIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length); } } else if (IS_SPACE_REGEXP.test(target.charAt(index - 1))) { score *= SCORE_SPACE_WORD_JUMP; const spaceBreaks = target.slice(targetIndex, index - 1).match(COUNT_SPACE_REGEXP); if (spaceBreaks && targetIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length); } } else { score *= SCORE_CHARACTER_JUMP; if (targetIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, index - targetIndex); } } if (target.charAt(index) !== query.charAt(queryIndex)) { score *= PENALTY_CASE_MISMATCH; } } if ((score < SCORE_TRANSPOSITION && lowerTarget.charAt(index - 1) === lowerQuery.charAt(queryIndex + 1)) || // Allow duplicate letters (cmdk ref #7428) (lowerQuery.charAt(queryIndex + 1) === lowerQuery.charAt(queryIndex) && lowerTarget.charAt(index - 1) !== lowerQuery.charAt(queryIndex))) { const transposedScore = commandScoreInner(target, query, lowerTarget, lowerQuery, index + 1, queryIndex + 2, memo); if (transposedScore * SCORE_TRANSPOSITION > score) { score = transposedScore * SCORE_TRANSPOSITION; } } if (score > highScore) { highScore = score; } index = lowerTarget.indexOf(queryChar, index + 1); } memo[memoKey] = highScore; return highScore;}/** * @description Lowercases a string and folds every space-like character to a * plain space so variants match each other. * * @param value - The string to normalize. * @returns The normalized string. * @private */function formatInput(value) { return value.toLowerCase().replace(COUNT_SPACE_REGEXP, " ");}/** * @description Scores how well `query` matches `target`, optionally widening * the candidate with alias strings (extra keywords that should also match). * * @param target - The candidate string to score against. * @param query - What the user typed. * @param aliases - Extra match targets appended to the candidate. * @returns 0 (no match) to 1 (perfect continuous match). */function commandScore(target, query, aliases = []) { const haystack = aliases.length > 0 ? `${target} ${aliases.join(" ")}` : target; return commandScoreInner(haystack, query, formatInput(haystack), formatInput(query), 0, 0, {});}export { commandScore };// base/hotkeys.js"use strict";// --- Parsing ---/** * @description Parses a `"mod+shift+p"`-style spec into a structured hotkey. * * @param spec - The hotkey spec, `+`-separated, case-insensitive. * @returns The parsed hotkey, or null for an empty/invalid spec. */function parseHotkey(spec) { const tokens = spec .split("+") .map((token) => token.trim().toLowerCase()) .filter((token) => token.length > 0); if (tokens.length === 0) return null; const hotkey = { key: "", ctrl: false, alt: false, shift: false, meta: false, mod: false, }; for (const token of tokens.slice(0, -1)) { switch (token) { case "mod": hotkey.mod = true; break; case "ctrl": case "control": hotkey.ctrl = true; break; case "alt": case "option": hotkey.alt = true; break; case "shift": hotkey.shift = true; break; case "meta": case "cmd": case "super": hotkey.meta = true; break; default: // Unknown modifier: reject rather than silently matching too much return null; } } hotkey.key = tokens[tokens.length - 1]; return hotkey;}// --- Matching ---/** * @description Whether the current platform treats Meta as the primary * modifier (macOS, iOS, iPadOS). * * @returns True on Apple platforms. * @private */function isApplePlatform() { if (typeof navigator === "undefined") return false; return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent);}/** * @description Matches a keyboard event against a parsed hotkey. Modifier * states must match exactly (`mod+k` does not fire on `mod+shift+k`) except * that `mod` claims whichever of Meta/Control the platform assigns it. * * @param event - The keyboard event to test. * @param hotkey - The parsed hotkey to match. * @param apple - Platform override for tests; defaults to detection. * @returns True when the event is exactly this hotkey. */function matchesHotkey(event, hotkey, apple = isApplePlatform()) { const wantMeta = hotkey.meta || (hotkey.mod && apple); const wantCtrl = hotkey.ctrl || (hotkey.mod && !apple); if (event.metaKey !== wantMeta) return false; if (event.ctrlKey !== wantCtrl) return false; if (event.altKey !== hotkey.alt) return false; if (event.shiftKey !== hotkey.shift) return false; return event.key.toLowerCase() === hotkey.key;}/** * @description Whether a node is an editable context (form field or * contenteditable), where bare-key hotkeys must not fire. * * @param node - The event target to inspect. * @returns True when typing belongs to the node, not to hotkeys. */function isEditableTarget(node) { if (!(node instanceof HTMLElement)) return false; if (node.isContentEditable) return true; return (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement);}/** * @description Whether a hotkey uses no modifier at all (a bare key), which * should be suppressed in editable contexts. * * @param hotkey - The parsed hotkey. * @returns True when no modifier is required. */function isBareKey(hotkey) { return !hotkey.ctrl && !hotkey.alt && !hotkey.meta && !hotkey.mod;}/** * @description Binds a document-level listener for one hotkey spec. Bare-key * specs are suppressed while focus is in an editable context; matches call * `preventDefault()` before the callback. * * @param spec - The hotkey spec, e.g. `"mod+k"`. * @param callback - Runs on each match, receiving the keyboard event. * @param options - `signal` unbinds the listener when aborted. * @returns True when the spec parsed and the listener was bound. */function bindHotkey(spec, callback, options = {}) { const hotkey = parseHotkey(spec); if (!hotkey || typeof document === "undefined") return false; document.addEventListener("keydown", (event) => { if (event.defaultPrevented || event.repeat) return; if (isBareKey(hotkey) && isEditableTarget(event.target)) return; if (!matchesHotkey(event, hotkey)) return; event.preventDefault(); callback(event); }, { signal: options.signal }); return true;}export { parseHotkey, matchesHotkey, isEditableTarget, isBareKey, bindHotkey };// base/typeahead.js"use strict";/** * @fileoverview Shared typeahead engine for autocomplete, combobox, and command. * @description The common runtime behind the filter-as-you-type family: a * query signal fed by the input, score-based ranking of real DOM items * (`commandScore`), active-item keyboard navigation with * `aria-activedescendant` (focus never leaves the input), and, for form * controls whose panel sits outside the input, ownership of a * `popover="manual"` panel with outside-pointerdown and Escape close paths. * * Subclasses (`UiAutocomplete`, `UiCombobox`, `UiCommand`) declare their slot * prefix and commit semantics; everything else lives here. Filtering only * writes `hidden` and (when ranking) inline `order`: the DOM is never * restructured, so forms, focus, and progressive enhancement stay untouched. * Groups and empty states hide with CSS `:has()`, not code. * * Item facts come from the markup: the match/commit text is `data-value` * (falling back to text content) and `data-keywords` adds extra match * targets. `data-sort="score"` on the root re-ranks visually via `order`. */import { commandScore } from "./command-score.js";import { ZazzElement } from "./zazz-element.js";import { effect, state } from "./signals.js";/** * @description Scores every item against the query. An empty query leaves all * items visible with a neutral score. * * @param query - What the user typed. * @param items - Facts for each item, in DOM order. * @returns One verdict per item, same order as the input. */function rankItems(query, items) { const trimmed = query.trim(); return items.map((item, index) => { const score = trimmed === "" ? 1 : commandScore(item.value, trimmed, item.keywords); return { index, score, hidden: score <= 0 }; });}/** * @description Reducer for active-item keyboard navigation. ArrowDown from * nothing highlights the first item, ArrowUp from nothing the last; both wrap. * * @param current - The currently active index, -1 for none. * @param key - The `KeyboardEvent.key` pressed. * @param count - How many items are visible. * @returns The next active index, -1 when there is nothing to highlight. */function nextActiveIndex(current, key, count) { if (count === 0) return -1; switch (key) { case "ArrowDown": return current < 0 ? 0 : (current + 1) % count; case "ArrowUp": return current < 0 ? count - 1 : (current - 1 + count) % count; case "Home": return 0; case "End": return count - 1; default: return current; }}// --- Element base ---let typeaheadIdCounter = 0;/** * @description Base class for the typeahead family. Subclasses set the slot * prefix and commit behavior; the base owns query state, ranking, keyboard * navigation, ARIA wiring, and (when `managesPanel`) the manual popover. */class TypeaheadElement extends ZazzElement { /** Whether this element opens/closes its own `popover="manual"` panel. */ managesPanel = true; /** * Whether focusing the input opens the panel. Combobox opts out: its input * holds a committed display value, so tabbing through a form must not pop * the list open, any more than tabbing to a select opens its picker. */ openOnFocus = true; /** Whether ranking also re-orders visually via inline `order`. */ get sortByScore() { return this.getAttribute("data-sort") === "score"; } /** Whether filtering auto-highlights the best item (command palettes do). */ autoHighlight = false; searchInput = null; panel = null; query = state(""); open = state(false); activeIndex = state(-1); setup(signal) { const prefix = this.slotPrefix; const panel = this.querySelector(`[data-slot~="${prefix}-panel"]`); const input = this.querySelector('input[role="combobox"]') ?? this.querySelector(`[data-slot~="${prefix}-input"]`); if (!(panel instanceof HTMLElement) || !(input instanceof HTMLInputElement)) return; this.panel = panel; this.searchInput = input; const list = panel.querySelector(`[data-slot~="${prefix}-list"]`); if (list instanceof HTMLElement) { list.id ||= `ui-${prefix}-list-${++typeaheadIdCounter}`; input.setAttribute("aria-controls", list.id); } // Input adapters: DOM events only write signals input.addEventListener("input", () => { this.query.set(input.value); this.activeIndex.set(this.autoHighlight ? 0 : -1); if (this.managesPanel && !this.#inlinePanel()) { this.open.set(input.value.length >= this.#minLength()); } }, { signal }); input.addEventListener("keydown", (event) => this.#onKeydown(event), { signal }); if (this.#inlinePanel()) { // Inline variant: a panel without [popover] renders in flow and is // always open; there is nothing to show, hide, or light-dismiss this.open.set(true); input.setAttribute("aria-expanded", "true"); } else if (this.managesPanel) { if (this.openOnFocus) { input.addEventListener("focus", () => { if (input.value.length >= this.#minLength()) this.open.set(true); }, { signal }); } // Outside pointerdown closes; inside the panel it must not steal focus document.addEventListener("pointerdown", (event) => { if (!(event.target instanceof Node)) return; if (this.contains(event.target)) return; this.open.set(false); }, { signal }); this.addEventListener("focusout", (event) => { const next = event.relatedTarget; if (next instanceof Node && this.contains(next)) return; this.open.set(false); }, { signal }); } else if (panel instanceof HTMLDialogElement) { // Native <dialog> surface: mirror the dialog-lifecycle events this.addEventListener("zazz:dialog-open", (event) => { if (event.target !== panel) return; this.open.set(true); input.focus(); }, { signal }); this.addEventListener("zazz:dialog-close", (event) => { if (event.target !== panel) return; this.open.set(false); }, { signal }); } else { // Native popover="auto" surface owns open/close; mirror it panel.addEventListener("toggle", (event) => { const opened = event.newState === "open"; this.open.set(opened); if (opened) input.focus(); }, { signal }); } // Keep focus in the input. List rows are not focusable, so a mousedown on // one blurs the input, and the resulting focusout closes the panel (and, in // combobox, reverts the query) *before* the click that commits, and the // commit's own input.focus() then re-fires the focus handler that reopens // it. mousedown, not pointerdown: preventing pointerdown would also cancel // touch panning inside the scrollable panel. Scoped to the list rather than // the whole panel so native scrollbar dragging and header text selection // survive. Suppressing focus transfer does not suppress the click, so link // and invoker items still activate. panel.addEventListener("mousedown", (event) => { if (!(event.target instanceof Element)) return; if (event.target.closest("input, textarea, select, [contenteditable]")) return; if (!event.target.closest(`[data-slot~="${prefix}-list"]`)) return; event.preventDefault(); }, { signal }); // Item click = commit (the mousedown guard above keeps focus in the input, // so no focusout races the click) panel.addEventListener("click", (event) => { if (!(event.target instanceof Element)) return; const item = event.target.closest(`[data-slot~="${prefix}-item"]`); if (item && !item.hidden) this.commit(item, "pointer"); }, { signal }); // Output adapter 1: panel visibility and expanded state (the inline // variant is unconditionally open, so it binds no visibility effect) if (this.#inlinePanel()) { // Nothing to drive } else if (this.managesPanel) { effect(() => { const opened = this.open.get(); input.setAttribute("aria-expanded", String(opened)); if (opened && !this.#panelOpen()) panel.showPopover(); else if (!opened && this.#panelOpen()) panel.hidePopover(); if (!opened) this.activeIndex.set(-1); }, { signal }); } else { effect(() => { input.setAttribute("aria-expanded", String(this.open.get())); }, { signal }); } // Output adapter 2: ranking, visibility, highlight, activedescendant. // All three signals are read up front so every run tracks the same set; // `open` both gates and subscribes. effect(() => { const open = this.open.get(); const query = this.query.get(); const active = this.activeIndex.get(); // Re-filtering the list while the popover is still fading out is the // visible "flash on close": committing or reverting clears the query, // and the rows collapse or re-expand mid-transition. While a // self-managed panel is closed nothing is written: the rows keep their // last filter state and highlight through the exit transition, and // reopening re-ranks in the same microtask drain as showPopover(), so // both land before one paint. The inline variant has no panel to fade, // and a panel owned by a native surface (command) can become visible // before its `toggle` task mirrors `open`, so neither is gated. if (!open && this.managesPanel && !this.#inlinePanel()) { input.removeAttribute("aria-activedescendant"); return; } const { items, ranked, visible } = this.#rank(query); const sort = this.sortByScore; for (const verdict of ranked) { const item = items[verdict.index]; item.hidden = verdict.hidden; if (sort) item.style.order = String(-Math.round(verdict.score * 1000)); else if (item.style.order) item.style.removeProperty("order"); } visible.forEach((item, index) => { item.id ||= `ui-${prefix}-item-${++typeaheadIdCounter}`; if (index === active) item.setAttribute("data-highlighted", ""); else item.removeAttribute("data-highlighted"); }); const highlighted = active >= 0 ? visible[active] : undefined; if (highlighted) { input.setAttribute("aria-activedescendant", highlighted.id); highlighted.scrollIntoView({ block: "nearest" }); } else { input.removeAttribute("aria-activedescendant"); } }, { signal }); } /** * @description The panel's items, in DOM order. * * @returns Every `<prefix>-item` element in the panel. */ items() { const panel = this.panel; if (!panel) return []; return Array.from(panel.querySelectorAll(`[data-slot~="${this.slotPrefix}-item"]`)).filter((node) => node instanceof HTMLElement); } /** * @description The visible items in visual order: score order when * `data-sort="score"`, DOM order otherwise, so arrow keys always follow * what the user sees. * * @param items - All items, DOM order. * @param ranked - The ranker's verdicts for those items. * @returns Visible items in visual order. */ visibleItems(items, ranked) { const visible = ranked.filter((verdict) => !verdict.hidden); if (this.sortByScore) visible.sort((a, b) => b.score - a.score || a.index - b.index); return visible.map((verdict) => items[verdict.index]); } /** * @description Ranks every item against a query without touching the DOM. * Keyboard navigation must not read `item.hidden`: the output effect above * deliberately leaves those flags stale while the panel is closed, so a fresh * ranking is the only trustworthy filter state. * * @param query - What the user typed. * @returns The items in DOM order, their verdicts, and the visible subset in * visual order. * @private */ #rank(query) { const items = this.items(); const ranked = rankItems(query, items.map((item) => ({ value: this.itemValue(item), keywords: (item.getAttribute("data-keywords") ?? "").split(/\s+/).filter(Boolean), }))); return { items, ranked, visible: this.visibleItems(items, ranked) }; } /** * @description The text an item matches and commits with. The text-content * fallback excludes `<kbd>` shortcut hints, which are presentation, not * value ("Go to docs ⇧⌘D" matches and announces as "Go to docs"). * * @param item - The item element. * @returns `data-value` when present, trimmed hint-free text otherwise. */ itemValue(item) { const explicit = item.getAttribute("data-value"); if (explicit !== null) return explicit; if (!item.querySelector("kbd, ui-kbd-group")) return item.textContent?.trim() ?? ""; const clone = item.cloneNode(true); for (const hint of clone.querySelectorAll("kbd, ui-kbd-group")) hint.remove(); return clone.textContent?.trim() ?? ""; } /** * @description Escape with the panel already closed. Autocomplete and command * empty the field; combobox overrides this, because its input carries a * committed display value rather than the filter. */ clearQuery() { const input = this.searchInput; if (!input) return; input.value = ""; this.query.set(""); } /** * @description Minimum query length before the panel opens (`data-min-length`). * * @returns The threshold, 0 by default. * @private */ #minLength() { const raw = Number(this.getAttribute("data-min-length")); return Number.isFinite(raw) && raw > 0 ? raw : 0; } /** * @description Whether the panel is the inline variant: rendered in flow * without `[popover]`, and therefore always open. * * @returns True for an inline panel. * @private */ #inlinePanel() { return this.managesPanel && this.panel !== null && !this.panel.hasAttribute("popover"); } /** * @description Whether the panel popover is currently shown (polyfill-aware). * * @returns True when open. * @private */ #panelOpen() { return this.panel?.matches(":popover-open, .\\:popover-open") ?? false; } /** * @description Keyboard contract on the search input: arrows/Home/End move * the highlight, Enter commits it, Escape closes then clears (only when the * element manages its own panel: native surfaces own Escape themselves). * * @param event - The keydown event. * @private */ #onKeydown(event) { const input = this.searchInput; if (!input) return; if (event.key === "Escape" && this.managesPanel) { // The inline variant has no panel to close: Escape only clears if (this.open.get() && !this.#inlinePanel()) { event.preventDefault(); this.open.set(false); } else if (input.value) { event.preventDefault(); this.clearQuery(); } return; } if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { // Home/End belong to the text caret unless an item is already active if ((event.key === "Home" || event.key === "End") && this.activeIndex.get() < 0) return; event.preventDefault(); if (this.managesPanel && !this.open.get()) this.open.set(true); const count = this.#rank(this.query.get()).visible.length; this.activeIndex.set(nextActiveIndex(this.activeIndex.get(), event.key, count)); return; } if (event.key === "Enter") { const active = this.activeIndex.get(); const item = active >= 0 ? this.#rank(this.query.get()).visible[active] : undefined; if (item) { event.preventDefault(); this.commit(item, "keyboard"); } } }}export { TypeaheadElement, rankItems, nextActiveIndex };// primitives/command/command.js"use strict";/** * @fileoverview `<ui-command>`: a command menu for search and quick actions. * @description Light-DOM custom element on the shared typeahead engine * (`base/typeahead.ts`), architected after cmdk. The panel (a `popover="auto"` * dropdown or a `<dialog class="ui-dialog">`) contains the search input; * items rank by match score and the best match auto-highlights. * * Actions are the platform's own vocabulary: navigation items are real * `<a href>` links, and action items are `<button command commandfor>` invoker * buttons (including custom `--commands`). Enter activates the highlighted * item exactly as a click would; every activation also dispatches a bubbling * `zazz:command-select` CustomEvent (`detail: { item, value }`) from the root. * Without JavaScript the trigger still opens the panel natively and every * item still works: only filtering, highlight, and hotkeys are lost. * * Attributes: * - `data-command-hotkey` (root): global toggle shortcut, e.g. `"mod+k"`. * - `data-hotkey` (item): global accelerator that activates the item, active * while the element is connected (even with the panel closed). * - `data-stay-open` (item): keep the panel open after activation. * - `data-sort="document"` (root): opt out of score ranking (default: score). * * Parts: `command-open` (trigger), `command-panel`, `command-header`, * `command-input`, `command-list`, `command-group` / `command-group-label`, * `command-item` (`data-value`, `data-keywords`), `command-kbd`, * `command-footer`, `command-empty`. * * For complex custom actions, see the example `command-actions.ts`: listen * for your `--command` on its target, or for `zazz:command-select` on the root. */import { TypeaheadElement } from "../../base/typeahead.js";import { bindHotkey } from "../../base/hotkeys.js";import { defineZazzElement } from "../../base/zazz-element.js";class UiCommand extends TypeaheadElement { slotPrefix = "command"; managesPanel = false; autoHighlight = true; /** Command ranks by score unless the author opts back into DOM order. */ get sortByScore() { return this.getAttribute("data-sort") !== "document"; } setup(signal) { super.setup(signal); const input = this.searchInput; const panel = this.panel; if (!input || !panel) return; // Global toggle shortcut on the root const toggleSpec = this.getAttribute("data-command-hotkey"); if (toggleSpec) { bindHotkey(toggleSpec, () => this.#togglePanel(), { signal }); } // Per-item accelerators: global while connected, panel open or not for (const item of this.items()) { const spec = item.getAttribute("data-hotkey"); if (spec) { bindHotkey(spec, () => this.#activate(item, true), { signal }); } } // Reset the search whenever the panel closes, so it reopens fresh const reset = () => { input.value = ""; this.query.set(""); this.activeIndex.set(0); }; if (panel instanceof HTMLDialogElement) { this.addEventListener("zazz:dialog-close", (event) => { if (event.target === panel) reset(); }, { signal }); } else { panel.addEventListener("toggle", (event) => { if (event.newState === "closed") reset(); }, { signal }); } } /** * @description A pointer commit has already run the item's native activation * (link navigation, invoker command); a keyboard commit runs it via * `click()`. Both announce `zazz:command-select` and close the panel unless * the item asks to stay open. * * @param item - The activated item. * @param source - How the commit happened. */ commit(item, source) { this.#activate(item, source === "keyboard"); } /** * @description Runs one item's activation: optional synthetic click, * `zazz:command-select`, then close (unless `data-stay-open`). * * @param item - The item to activate. * @param click - Whether to run the native activation via `click()`. * @private */ #activate(item, click) { this.dispatchEvent(new CustomEvent("zazz:command-select", { bubbles: true, detail: { item, value: this.itemValue(item) }, })); if (click) item.click(); if (!item.hasAttribute("data-stay-open")) this.#closePanel(); } /** * @description Opens or closes the panel, branching on its surface. * @private */ #togglePanel() { const panel = this.panel; if (!panel) return; if (panel instanceof HTMLDialogElement) { if (panel.open) panel.close(); else panel.showModal(); } else { panel.togglePopover(); } } /** * @description Closes the panel, branching on its surface. * @private */ #closePanel() { const panel = this.panel; if (!panel) return; if (panel instanceof HTMLDialogElement) { if (panel.open) panel.close(); } else if (panel.matches(":popover-open, .\\:popover-open")) { panel.hidePopover(); } }}defineZazzElement("ui-command", UiCommand);export { UiCommand };/** * command.css: Command (ui-command | .ui-command, [data-slot~="command-panel"]) * * @layer variables, components * @requires layers.css, _variables.css, popover.css, button.css, fields.css, * dialog.css, menu.css, select.css (--ui-option-*), kbd.css (--ui-kbd-group-*) * @uses popovertarget + [popover="auto"]: dropdown surface, native light dismiss * @uses <dialog class="ui-dialog"> + command="show-modal": focused surface * @uses anchor-name, anchor-scope, position-anchor: tether the popover form * @uses :has(): group + empty-state visibility without JavaScript * @tokens --ui-command-* (@layer variables); items alias --ui-option-* * @see command.ts: ranking, hotkeys, and activation behavior */@layer variables { :root { --ui-command-min-inline-size: var(--step-96); --ui-command-max-block-size: var(--step-96); --ui-command-option-gap: var(--ui-field-option-gap); --ui-command-input-padding: var(--step-3); --ui-command-input-border: 1px solid var(--border); --ui-command-group-label-padding: var(--step-2) var(--step-2) var(--step-1); --ui-command-footer-padding: var(--step-2); --ui-command-footer-border: 1px solid var(--border); --ui-command-shadow: var(--ui-menu-shadow); }}@layer zazz.components { /* =========================================================================== COMMAND: palette panel (popover or dialog) with the input inside - The popover form rides popover.css's surface + placement; the dialog form rides dialog.css. This file shapes the interior: borderless input over a hairline, scrollable list, eyebrow group labels, hint footer. =========================================================================== */ :where(ui-command, .ui-command) { anchor-scope: --ui-command-trigger; display: inline-flex; } :where(ui-command, .ui-command) > :where([popovertarget], [command]) { anchor-name: --ui-command-trigger; } [data-slot~="command-panel"] { /* flex-direction stays ungated so the close fade (display held by the allow-discrete transition) keeps the column layout to the last frame */ flex-direction: column; min-inline-size: var(--ui-command-min-inline-size); box-shadow: var(--ui-command-shadow); } /* Only display is gated; unconditional it would defeat the UA's display: none on closed popovers and dialogs */ [data-slot~="command-panel"]:where(:popover-open, .\:popover-open, [open]) { display: flex; } [data-slot~="command-panel"]:where([popover]) { position-anchor: --ui-command-trigger; padding: 0; } /* Dialog form: the palette owns its interior, edge to edge */ [data-slot~="command-panel"]:where(dialog) { --ui-dialog-width: min(var(--breakpoint-sm), 100% - var(--gutters) * 2); padding: 0; } /* Search input: borderless over a hairline; the panel is the surface */ [data-slot~="command-header"] { display: flex; align-items: center; border-block-end: var(--ui-command-input-border); } [data-slot~="command-input"] { flex: 1; min-inline-size: 0; padding: var(--ui-command-input-padding); font: inherit; font-size: var(--ui-field-font-size); color: inherit; background: none; border: none; outline: none; } [data-slot~="command-list"] { display: flex; flex-direction: column; gap: var(--ui-command-option-gap); max-block-size: var(--ui-command-max-block-size); padding: var(--ui-popover-padding); margin: 0; overflow: auto; list-style: none; } [data-slot~="command-group"] { display: flex; flex-direction: column; gap: var(--ui-command-option-gap); } [data-slot~="command-group-label"] { padding: var(--ui-command-group-label-padding); /* Score ranking writes order -1000..0 on sibling items; keep the label first */ order: -1001; } [data-slot~="command-item"] { cursor: pointer; } [data-slot~="command-item"][data-highlighted] { --ui-button-background: var(--ui-button-background--hover); --ui-button-foreground: var(--ui-button-foreground--hover); } /* Shortcut hint: a lone <kbd> or a <ui-kbd-group> run pushes to the end; spacing between keys inside a run is the group's --ui-kbd-group-gap. */ [data-slot~="command-item"] > :where(kbd, ui-kbd-group, .ui-kbd-group) { margin-inline-start: auto; } [data-slot~="command-footer"] { display: flex; align-items: center; gap: var(--step-2); padding: var(--ui-command-footer-padding); border-block-start: var(--ui-command-footer-border); color: var(--muted-foreground); font-size: var(--font-size-xs); } [data-slot~="command-group"]:not(:has([data-slot~="command-item"]:not([hidden]))) { display: none; } [data-slot~="command-empty"] { display: none; padding: var(--step-2); } [data-slot~="command-panel"]:not(:has([data-slot~="command-item"]:not([hidden]))) [data-slot~="command-empty"] { display: block; }}Custom actions
Simple actions need no script. For custom logic, register a --command handler on the invoker target, or listen for zazz:command-select. The loaded command-actions.js in this example is a template you can adapt.
<!-- command-actions.js (loaded with this example) shows how to register custom actions: listen for your `--command` on its `commandfor` target, or for `zazz:command-select` on the root. Copy it into your project as a starting point. --><ui-command data-command-hotkey="mod+j" id="command-actions-target" class="w-full max-w-xl"> <button class="ui-button w-full justify-between" type="button" data-variant="outline" popovertarget="command-example-actions-1" data-slot="command-open" > <span class="text-muted-foreground">Quick actions...</span> <ui-kbd-group><kbd>⌘</kbd><kbd>J</kbd></ui-kbd-group> </button> <div id="command-example-actions-1" data-slot="command-panel" data-side="bottom" data-align="start" popover="auto" > <header data-slot="command-header"> <input data-slot="command-input" type="text" placeholder="Type a command..." role="combobox" aria-expanded="false" aria-autocomplete="list" autocomplete="off" aria-label="Search commands" /> </header> <div role="listbox" data-slot="command-list" aria-label="Commands"> <button role="option" data-slot="command-item" class="ui-button justify-start" data-variant="ghost" type="button" command="--theme-toggle" commandfor="command-actions-target" tabindex="-1" data-keywords="dark light mode appearance" data-stay-open > Toggle theme </button> <button role="option" data-slot="command-item" class="ui-button justify-start" data-variant="ghost" type="button" command="--copy-link" commandfor="command-actions-target" tabindex="-1" data-keywords="share url clipboard" > Copy link </button> <button role="option" data-slot="command-item" class="ui-button justify-start" data-variant="ghost" type="button" command="--toast-success" commandfor="command-actions-toaster" data-title="Saved" data-description="Your changes are safe." tabindex="-1" data-keywords="notify notification" > Show a toast </button> </div> <p data-slot="command-empty" class="text-muted-foreground">No commands found.</p> <footer data-slot="command-footer"> <span ><ui-kbd-group><kbd>↑</kbd><kbd>↓</kbd></ui-kbd-group> navigate</span > <span><kbd>↵</kbd> select</span> </footer> </div></ui-command><ui-toaster id="command-actions-toaster" data-position="bottom-end"></ui-toaster>// base/command-score.js"use strict";/** * @fileoverview Fuzzy command-scoring for the typeahead family. * @description Rates how well a search query matches a candidate string, * returning 0 (no match) to 1 (perfect continuous match). Continuous runs and * word-boundary jumps score high; scattered character jumps, transpositions, * case mismatches, and skipped characters decay the score. Autocomplete, * combobox, and command all rank their items with this single function. * * Vendored from cmdk's `command-score.ts` by @pacocoursey (MIT), itself * adapted from Superhuman's `command-score` (MIT), which builds on Joshaven * Potter's `string_score`. The constants and recursion are kept verbatim so * results match cmdk's ranking; only the module shape is Zazz's. * * @see https://github.com/pacocoursey/cmdk * @see https://github.com/superhuman/command-score */// --- Scoring constants ---// The scores are arranged so that a continuous match of characters will// result in a total score of 1.//// The best case: this character is a match, and either this is the start of// the string or the previous character was also a match.const SCORE_CONTINUE_MATCH = 1;// A new match at the start of a word scores better than a new match// elsewhere, as it's more likely that the user will type the starts of// fragments. Word jumps between spaces score slightly higher than slashes,// brackets, hyphens, etc.const SCORE_SPACE_WORD_JUMP = 0.9;const SCORE_NON_SPACE_WORD_JUMP = 0.8;// Any other match isn't ideal, but is included for completeness.const SCORE_CHARACTER_JUMP = 0.17;// If the user transposed two letters, it should be significantly penalized:// "ouch" is more likely than "curtain" when "uc" is typed.const SCORE_TRANSPOSITION = 0.1;// The goodness of a match decays slightly with each skipped character:// "bad" is more likely than "bard" when "bd" is typed.const PENALTY_SKIPPED = 0.999;// An exact-case match beats a case-insensitive match by a small amount:// "HTML" is more likely than "haml" when "HM" is typed.const PENALTY_CASE_MISMATCH = 0.9999;// If the candidate has more characters than the user typed, penalize// slightly: "html" is more likely than "html5" when "html" is typed.const PENALTY_NOT_COMPLETE = 0.99;const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/;const COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g;const IS_SPACE_REGEXP = /[\s-]/;const COUNT_SPACE_REGEXP = /[\s-]/g;// --- Scoring ---/** * @description Recursive scorer over (candidate index, query index) pairs, * memoized per pair so repeated subproblems resolve in constant time. * * @param target - The candidate string (original casing). * @param query - The query string (original casing). * @param lowerTarget - Pre-lowercased candidate. * @param lowerQuery - Pre-lowercased query. * @param targetIndex - Current position in the candidate. * @param queryIndex - Current position in the query. * @param memo - Shared memoization table for this scoring run. * @returns The best score reachable from this position. * @private */function commandScoreInner(target, query, lowerTarget, lowerQuery, targetIndex, queryIndex, memo) { if (queryIndex === query.length) { if (targetIndex === target.length) { return SCORE_CONTINUE_MATCH; } return PENALTY_NOT_COMPLETE; } const memoKey = `${targetIndex},${queryIndex}`; const memoized = memo[memoKey]; if (memoized !== undefined) { return memoized; } const queryChar = lowerQuery.charAt(queryIndex); let index = lowerTarget.indexOf(queryChar, targetIndex); let highScore = 0; while (index >= 0) { let score = commandScoreInner(target, query, lowerTarget, lowerQuery, index + 1, queryIndex + 1, memo); if (score > highScore) { if (index === targetIndex) { score *= SCORE_CONTINUE_MATCH; } else if (IS_GAP_REGEXP.test(target.charAt(index - 1))) { score *= SCORE_NON_SPACE_WORD_JUMP; const wordBreaks = target.slice(targetIndex, index - 1).match(COUNT_GAPS_REGEXP); if (wordBreaks && targetIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length); } } else if (IS_SPACE_REGEXP.test(target.charAt(index - 1))) { score *= SCORE_SPACE_WORD_JUMP; const spaceBreaks = target.slice(targetIndex, index - 1).match(COUNT_SPACE_REGEXP); if (spaceBreaks && targetIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length); } } else { score *= SCORE_CHARACTER_JUMP; if (targetIndex > 0) { score *= Math.pow(PENALTY_SKIPPED, index - targetIndex); } } if (target.charAt(index) !== query.charAt(queryIndex)) { score *= PENALTY_CASE_MISMATCH; } } if ((score < SCORE_TRANSPOSITION && lowerTarget.charAt(index - 1) === lowerQuery.charAt(queryIndex + 1)) || // Allow duplicate letters (cmdk ref #7428) (lowerQuery.charAt(queryIndex + 1) === lowerQuery.charAt(queryIndex) && lowerTarget.charAt(index - 1) !== lowerQuery.charAt(queryIndex))) { const transposedScore = commandScoreInner(target, query, lowerTarget, lowerQuery, index + 1, queryIndex + 2, memo); if (transposedScore * SCORE_TRANSPOSITION > score) { score = transposedScore * SCORE_TRANSPOSITION; } } if (score > highScore) { highScore = score; } index = lowerTarget.indexOf(queryChar, index + 1); } memo[memoKey] = highScore; return highScore;}/** * @description Lowercases a string and folds every space-like character to a * plain space so variants match each other. * * @param value - The string to normalize. * @returns The normalized string. * @private */function formatInput(value) { return value.toLowerCase().replace(COUNT_SPACE_REGEXP, " ");}/** * @description Scores how well `query` matches `target`, optionally widening * the candidate with alias strings (extra keywords that should also match). * * @param target - The candidate string to score against. * @param query - What the user typed. * @param aliases - Extra match targets appended to the candidate. * @returns 0 (no match) to 1 (perfect continuous match). */function commandScore(target, query, aliases = []) { const haystack = aliases.length > 0 ? `${target} ${aliases.join(" ")}` : target; return commandScoreInner(haystack, query, formatInput(haystack), formatInput(query), 0, 0, {});}export { commandScore };// base/hotkeys.js"use strict";// --- Parsing ---/** * @description Parses a `"mod+shift+p"`-style spec into a structured hotkey. * * @param spec - The hotkey spec, `+`-separated, case-insensitive. * @returns The parsed hotkey, or null for an empty/invalid spec. */function parseHotkey(spec) { const tokens = spec .split("+") .map((token) => token.trim().toLowerCase()) .filter((token) => token.length > 0); if (tokens.length === 0) return null; const hotkey = { key: "", ctrl: false, alt: false, shift: false, meta: false, mod: false, }; for (const token of tokens.slice(0, -1)) { switch (token) { case "mod": hotkey.mod = true; break; case "ctrl": case "control": hotkey.ctrl = true; break; case "alt": case "option": hotkey.alt = true; break; case "shift": hotkey.shift = true; break; case "meta": case "cmd": case "super": hotkey.meta = true; break; default: // Unknown modifier: reject rather than silently matching too much return null; } } hotkey.key = tokens[tokens.length - 1]; return hotkey;}// --- Matching ---/** * @description Whether the current platform treats Meta as the primary * modifier (macOS, iOS, iPadOS). * * @returns True on Apple platforms. * @private */function isApplePlatform() { if (typeof navigator === "undefined") return false; return /mac|iphone|ipad|ipod/i.test(navigator.platform || navigator.userAgent);}/** * @description Matches a keyboard event against a parsed hotkey. Modifier * states must match exactly (`mod+k` does not fire on `mod+shift+k`) except * that `mod` claims whichever of Meta/Control the platform assigns it. * * @param event - The keyboard event to test. * @param hotkey - The parsed hotkey to match. * @param apple - Platform override for tests; defaults to detection. * @returns True when the event is exactly this hotkey. */function matchesHotkey(event, hotkey, apple = isApplePlatform()) { const wantMeta = hotkey.meta || (hotkey.mod && apple); const wantCtrl = hotkey.ctrl || (hotkey.mod && !apple); if (event.metaKey !== wantMeta) return false; if (event.ctrlKey !== wantCtrl) return false; if (event.altKey !== hotkey.alt) return false; if (event.shiftKey !== hotkey.shift) return false; return event.key.toLowerCase() === hotkey.key;}/** * @description Whether a node is an editable context (form field or * contenteditable), where bare-key hotkeys must not fire. * * @param node - The event target to inspect. * @returns True when typing belongs to the node, not to hotkeys. */function isEditableTarget(node) { if (!(node instanceof HTMLElement)) return false; if (node.isContentEditable) return true; return (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement);}/** * @description Whether a hotkey uses no modifier at all (a bare key), which * should be suppressed in editable contexts. * * @param hotkey - The parsed hotkey. * @returns True when no modifier is required. */function isBareKey(hotkey) { return !hotkey.ctrl && !hotkey.alt && !hotkey.meta && !hotkey.mod;}/** * @description Binds a document-level listener for one hotkey spec. Bare-key * specs are suppressed while focus is in an editable context; matches call * `preventDefault()` before the callback. * * @param spec - The hotkey spec, e.g. `"mod+k"`. * @param callback - Runs on each match, receiving the keyboard event. * @param options - `signal` unbinds the listener when aborted. * @returns True when the spec parsed and the listener was bound. */function bindHotkey(spec, callback, options = {}) { const hotkey = parseHotkey(spec); if (!hotkey || typeof document === "undefined") return false; document.addEventListener("keydown", (event) => { if (event.defaultPrevented || event.repeat) return; if (isBareKey(hotkey) && isEditableTarget(event.target)) return; if (!matchesHotkey(event, hotkey)) return; event.preventDefault(); callback(event); }, { signal: options.signal }); return true;}export { parseHotkey, matchesHotkey, isEditableTarget, isBareKey, bindHotkey };// base/typeahead.js"use strict";/** * @fileoverview Shared typeahead engine for autocomplete, combobox, and command. * @description The common runtime behind the filter-as-you-type family: a * query signal fed by the input, score-based ranking of real DOM items * (`commandScore`), active-item keyboard navigation with * `aria-activedescendant` (focus never leaves the input), and, for form * controls whose panel sits outside the input, ownership of a * `popover="manual"` panel with outside-pointerdown and Escape close paths. * * Subclasses (`UiAutocomplete`, `UiCombobox`, `UiCommand`) declare their slot * prefix and commit semantics; everything else lives here. Filtering only * writes `hidden` and (when ranking) inline `order`: the DOM is never * restructured, so forms, focus, and progressive enhancement stay untouched. * Groups and empty states hide with CSS `:has()`, not code. * * Item facts come from the markup: the match/commit text is `data-value` * (falling back to text content) and `data-keywords` adds extra match * targets. `data-sort="score"` on the root re-ranks visually via `order`. */import { commandScore } from "./command-score.js";import { ZazzElement } from "./zazz-element.js";import { effect, state } from "./signals.js";/** * @description Scores every item against the query. An empty query leaves all * items visible with a neutral score. * * @param query - What the user typed. * @param items - Facts for each item, in DOM order. * @returns One verdict per item, same order as the input. */function rankItems(query, items) { const trimmed = query.trim(); return items.map((item, index) => { const score = trimmed === "" ? 1 : commandScore(item.value, trimmed, item.keywords); return { index, score, hidden: score <= 0 }; });}/** * @description Reducer for active-item keyboard navigation. ArrowDown from * nothing highlights the first item, ArrowUp from nothing the last; both wrap. * * @param current - The currently active index, -1 for none. * @param key - The `KeyboardEvent.key` pressed. * @param count - How many items are visible. * @returns The next active index, -1 when there is nothing to highlight. */function nextActiveIndex(current, key, count) { if (count === 0) return -1; switch (key) { case "ArrowDown": return current < 0 ? 0 : (current + 1) % count; case "ArrowUp": return current < 0 ? count - 1 : (current - 1 + count) % count; case "Home": return 0; case "End": return count - 1; default: return current; }}// --- Element base ---let typeaheadIdCounter = 0;/** * @description Base class for the typeahead family. Subclasses set the slot * prefix and commit behavior; the base owns query state, ranking, keyboard * navigation, ARIA wiring, and (when `managesPanel`) the manual popover. */class TypeaheadElement extends ZazzElement { /** Whether this element opens/closes its own `popover="manual"` panel. */ managesPanel = true; /** * Whether focusing the input opens the panel. Combobox opts out: its input * holds a committed display value, so tabbing through a form must not pop * the list open, any more than tabbing to a select opens its picker. */ openOnFocus = true; /** Whether ranking also re-orders visually via inline `order`. */ get sortByScore() { return this.getAttribute("data-sort") === "score"; } /** Whether filtering auto-highlights the best item (command palettes do). */ autoHighlight = false; searchInput = null; panel = null; query = state(""); open = state(false); activeIndex = state(-1); setup(signal) { const prefix = this.slotPrefix; const panel = this.querySelector(`[data-slot~="${prefix}-panel"]`); const input = this.querySelector('input[role="combobox"]') ?? this.querySelector(`[data-slot~="${prefix}-input"]`); if (!(panel instanceof HTMLElement) || !(input instanceof HTMLInputElement)) return; this.panel = panel; this.searchInput = input; const list = panel.querySelector(`[data-slot~="${prefix}-list"]`); if (list instanceof HTMLElement) { list.id ||= `ui-${prefix}-list-${++typeaheadIdCounter}`; input.setAttribute("aria-controls", list.id); } // Input adapters: DOM events only write signals input.addEventListener("input", () => { this.query.set(input.value); this.activeIndex.set(this.autoHighlight ? 0 : -1); if (this.managesPanel && !this.#inlinePanel()) { this.open.set(input.value.length >= this.#minLength()); } }, { signal }); input.addEventListener("keydown", (event) => this.#onKeydown(event), { signal }); if (this.#inlinePanel()) { // Inline variant: a panel without [popover] renders in flow and is // always open; there is nothing to show, hide, or light-dismiss this.open.set(true); input.setAttribute("aria-expanded", "true"); } else if (this.managesPanel) { if (this.openOnFocus) { input.addEventListener("focus", () => { if (input.value.length >= this.#minLength()) this.open.set(true); }, { signal }); } // Outside pointerdown closes; inside the panel it must not steal focus document.addEventListener("pointerdown", (event) => { if (!(event.target instanceof Node)) return; if (this.contains(event.target)) return; this.open.set(false); }, { signal }); this.addEventListener("focusout", (event) => { const next = event.relatedTarget; if (next instanceof Node && this.contains(next)) return; this.open.set(false); }, { signal }); } else if (panel instanceof HTMLDialogElement) { // Native <dialog> surface: mirror the dialog-lifecycle events this.addEventListener("zazz:dialog-open", (event) => { if (event.target !== panel) return; this.open.set(true); input.focus(); }, { signal }); this.addEventListener("zazz:dialog-close", (event) => { if (event.target !== panel) return; this.open.set(false); }, { signal }); } else { // Native popover="auto" surface owns open/close; mirror it panel.addEventListener("toggle", (event) => { const opened = event.newState === "open"; this.open.set(opened); if (opened) input.focus(); }, { signal }); } // Keep focus in the input. List rows are not focusable, so a mousedown on // one blurs the input, and the resulting focusout closes the panel (and, in // combobox, reverts the query) *before* the click that commits, and the // commit's own input.focus() then re-fires the focus handler that reopens // it. mousedown, not pointerdown: preventing pointerdown would also cancel // touch panning inside the scrollable panel. Scoped to the list rather than // the whole panel so native scrollbar dragging and header text selection // survive. Suppressing focus transfer does not suppress the click, so link // and invoker items still activate. panel.addEventListener("mousedown", (event) => { if (!(event.target instanceof Element)) return; if (event.target.closest("input, textarea, select, [contenteditable]")) return; if (!event.target.closest(`[data-slot~="${prefix}-list"]`)) return; event.preventDefault(); }, { signal }); // Item click = commit (the mousedown guard above keeps focus in the input, // so no focusout races the click) panel.addEventListener("click", (event) => { if (!(event.target instanceof Element)) return; const item = event.target.closest(`[data-slot~="${prefix}-item"]`); if (item && !item.hidden) this.commit(item, "pointer"); }, { signal }); // Output adapter 1: panel visibility and expanded state (the inline // variant is unconditionally open, so it binds no visibility effect) if (this.#inlinePanel()) { // Nothing to drive } else if (this.managesPanel) { effect(() => { const opened = this.open.get(); input.setAttribute("aria-expanded", String(opened)); if (opened && !this.#panelOpen()) panel.showPopover(); else if (!opened && this.#panelOpen()) panel.hidePopover(); if (!opened) this.activeIndex.set(-1); }, { signal }); } else { effect(() => { input.setAttribute("aria-expanded", String(this.open.get())); }, { signal }); } // Output adapter 2: ranking, visibility, highlight, activedescendant. // All three signals are read up front so every run tracks the same set; // `open` both gates and subscribes. effect(() => { const open = this.open.get(); const query = this.query.get(); const active = this.activeIndex.get(); // Re-filtering the list while the popover is still fading out is the // visible "flash on close": committing or reverting clears the query, // and the rows collapse or re-expand mid-transition. While a // self-managed panel is closed nothing is written: the rows keep their // last filter state and highlight through the exit transition, and // reopening re-ranks in the same microtask drain as showPopover(), so // both land before one paint. The inline variant has no panel to fade, // and a panel owned by a native surface (command) can become visible // before its `toggle` task mirrors `open`, so neither is gated. if (!open && this.managesPanel && !this.#inlinePanel()) { input.removeAttribute("aria-activedescendant"); return; } const { items, ranked, visible } = this.#rank(query); const sort = this.sortByScore; for (const verdict of ranked) { const item = items[verdict.index]; item.hidden = verdict.hidden; if (sort) item.style.order = String(-Math.round(verdict.score * 1000)); else if (item.style.order) item.style.removeProperty("order"); } visible.forEach((item, index) => { item.id ||= `ui-${prefix}-item-${++typeaheadIdCounter}`; if (index === active) item.setAttribute("data-highlighted", ""); else item.removeAttribute("data-highlighted"); }); const highlighted = active >= 0 ? visible[active] : undefined; if (highlighted) { input.setAttribute("aria-activedescendant", highlighted.id); highlighted.scrollIntoView({ block: "nearest" }); } else { input.removeAttribute("aria-activedescendant"); } }, { signal }); } /** * @description The panel's items, in DOM order. * * @returns Every `<prefix>-item` element in the panel. */ items() { const panel = this.panel; if (!panel) return []; return Array.from(panel.querySelectorAll(`[data-slot~="${this.slotPrefix}-item"]`)).filter((node) => node instanceof HTMLElement); } /** * @description The visible items in visual order: score order when * `data-sort="score"`, DOM order otherwise, so arrow keys always follow * what the user sees. * * @param items - All items, DOM order. * @param ranked - The ranker's verdicts for those items. * @returns Visible items in visual order. */ visibleItems(items, ranked) { const visible = ranked.filter((verdict) => !verdict.hidden); if (this.sortByScore) visible.sort((a, b) => b.score - a.score || a.index - b.index); return visible.map((verdict) => items[verdict.index]); } /** * @description Ranks every item against a query without touching the DOM. * Keyboard navigation must not read `item.hidden`: the output effect above * deliberately leaves those flags stale while the panel is closed, so a fresh * ranking is the only trustworthy filter state. * * @param query - What the user typed. * @returns The items in DOM order, their verdicts, and the visible subset in * visual order. * @private */ #rank(query) { const items = this.items(); const ranked = rankItems(query, items.map((item) => ({ value: this.itemValue(item), keywords: (item.getAttribute("data-keywords") ?? "").split(/\s+/).filter(Boolean), }))); return { items, ranked, visible: this.visibleItems(items, ranked) }; } /** * @description The text an item matches and commits with. The text-content * fallback excludes `<kbd>` shortcut hints, which are presentation, not * value ("Go to docs ⇧⌘D" matches and announces as "Go to docs"). * * @param item - The item element. * @returns `data-value` when present, trimmed hint-free text otherwise. */ itemValue(item) { const explicit = item.getAttribute("data-value"); if (explicit !== null) return explicit; if (!item.querySelector("kbd, ui-kbd-group")) return item.textContent?.trim() ?? ""; const clone = item.cloneNode(true); for (const hint of clone.querySelectorAll("kbd, ui-kbd-group")) hint.remove(); return clone.textContent?.trim() ?? ""; } /** * @description Escape with the panel already closed. Autocomplete and command * empty the field; combobox overrides this, because its input carries a * committed display value rather than the filter. */ clearQuery() { const input = this.searchInput; if (!input) return; input.value = ""; this.query.set(""); } /** * @description Minimum query length before the panel opens (`data-min-length`). * * @returns The threshold, 0 by default. * @private */ #minLength() { const raw = Number(this.getAttribute("data-min-length")); return Number.isFinite(raw) && raw > 0 ? raw : 0; } /** * @description Whether the panel is the inline variant: rendered in flow * without `[popover]`, and therefore always open. * * @returns True for an inline panel. * @private */ #inlinePanel() { return this.managesPanel && this.panel !== null && !this.panel.hasAttribute("popover"); } /** * @description Whether the panel popover is currently shown (polyfill-aware). * * @returns True when open. * @private */ #panelOpen() { return this.panel?.matches(":popover-open, .\\:popover-open") ?? false; } /** * @description Keyboard contract on the search input: arrows/Home/End move * the highlight, Enter commits it, Escape closes then clears (only when the * element manages its own panel: native surfaces own Escape themselves). * * @param event - The keydown event. * @private */ #onKeydown(event) { const input = this.searchInput; if (!input) return; if (event.key === "Escape" && this.managesPanel) { // The inline variant has no panel to close: Escape only clears if (this.open.get() && !this.#inlinePanel()) { event.preventDefault(); this.open.set(false); } else if (input.value) { event.preventDefault(); this.clearQuery(); } return; } if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) { // Home/End belong to the text caret unless an item is already active if ((event.key === "Home" || event.key === "End") && this.activeIndex.get() < 0) return; event.preventDefault(); if (this.managesPanel && !this.open.get()) this.open.set(true); const count = this.#rank(this.query.get()).visible.length; this.activeIndex.set(nextActiveIndex(this.activeIndex.get(), event.key, count)); return; } if (event.key === "Enter") { const active = this.activeIndex.get(); const item = active >= 0 ? this.#rank(this.query.get()).visible[active] : undefined; if (item) { event.preventDefault(); this.commit(item, "keyboard"); } } }}export { TypeaheadElement, rankItems, nextActiveIndex };// primitives/command/command.js"use strict";/** * @fileoverview `<ui-command>`: a command menu for search and quick actions. * @description Light-DOM custom element on the shared typeahead engine * (`base/typeahead.ts`), architected after cmdk. The panel (a `popover="auto"` * dropdown or a `<dialog class="ui-dialog">`) contains the search input; * items rank by match score and the best match auto-highlights. * * Actions are the platform's own vocabulary: navigation items are real * `<a href>` links, and action items are `<button command commandfor>` invoker * buttons (including custom `--commands`). Enter activates the highlighted * item exactly as a click would; every activation also dispatches a bubbling * `zazz:command-select` CustomEvent (`detail: { item, value }`) from the root. * Without JavaScript the trigger still opens the panel natively and every * item still works: only filtering, highlight, and hotkeys are lost. * * Attributes: * - `data-command-hotkey` (root): global toggle shortcut, e.g. `"mod+k"`. * - `data-hotkey` (item): global accelerator that activates the item, active * while the element is connected (even with the panel closed). * - `data-stay-open` (item): keep the panel open after activation. * - `data-sort="document"` (root): opt out of score ranking (default: score). * * Parts: `command-open` (trigger), `command-panel`, `command-header`, * `command-input`, `command-list`, `command-group` / `command-group-label`, * `command-item` (`data-value`, `data-keywords`), `command-kbd`, * `command-footer`, `command-empty`. * * For complex custom actions, see the example `command-actions.ts`: listen * for your `--command` on its target, or for `zazz:command-select` on the root. */import { TypeaheadElement } from "../../base/typeahead.js";import { bindHotkey } from "../../base/hotkeys.js";import { defineZazzElement } from "../../base/zazz-element.js";class UiCommand extends TypeaheadElement { slotPrefix = "command"; managesPanel = false; autoHighlight = true; /** Command ranks by score unless the author opts back into DOM order. */ get sortByScore() { return this.getAttribute("data-sort") !== "document"; } setup(signal) { super.setup(signal); const input = this.searchInput; const panel = this.panel; if (!input || !panel) return; // Global toggle shortcut on the root const toggleSpec = this.getAttribute("data-command-hotkey"); if (toggleSpec) { bindHotkey(toggleSpec, () => this.#togglePanel(), { signal }); } // Per-item accelerators: global while connected, panel open or not for (const item of this.items()) { const spec = item.getAttribute("data-hotkey"); if (spec) { bindHotkey(spec, () => this.#activate(item, true), { signal }); } } // Reset the search whenever the panel closes, so it reopens fresh const reset = () => { input.value = ""; this.query.set(""); this.activeIndex.set(0); }; if (panel instanceof HTMLDialogElement) { this.addEventListener("zazz:dialog-close", (event) => { if (event.target === panel) reset(); }, { signal }); } else { panel.addEventListener("toggle", (event) => { if (event.newState === "closed") reset(); }, { signal }); } } /** * @description A pointer commit has already run the item's native activation * (link navigation, invoker command); a keyboard commit runs it via * `click()`. Both announce `zazz:command-select` and close the panel unless * the item asks to stay open. * * @param item - The activated item. * @param source - How the commit happened. */ commit(item, source) { this.#activate(item, source === "keyboard"); } /** * @description Runs one item's activation: optional synthetic click, * `zazz:command-select`, then close (unless `data-stay-open`). * * @param item - The item to activate. * @param click - Whether to run the native activation via `click()`. * @private */ #activate(item, click) { this.dispatchEvent(new CustomEvent("zazz:command-select", { bubbles: true, detail: { item, value: this.itemValue(item) }, })); if (click) item.click(); if (!item.hasAttribute("data-stay-open")) this.#closePanel(); } /** * @description Opens or closes the panel, branching on its surface. * @private */ #togglePanel() { const panel = this.panel; if (!panel) return; if (panel instanceof HTMLDialogElement) { if (panel.open) panel.close(); else panel.showModal(); } else { panel.togglePopover(); } } /** * @description Closes the panel, branching on its surface. * @private */ #closePanel() { const panel = this.panel; if (!panel) return; if (panel instanceof HTMLDialogElement) { if (panel.open) panel.close(); } else if (panel.matches(":popover-open, .\\:popover-open")) { panel.hidePopover(); } }}defineZazzElement("ui-command", UiCommand);export { UiCommand };// primitives/command/command-actions.js"use strict";/** * @fileoverview Example: composing custom command actions for `<ui-command>`. * @description This file is a template, not part of the kit runtime: copy it * into your project and register your own actions. It is loaded only by the * `command-actions` docs example. * * Simple actions need no script at all: a navigation item is an `<a href>`, * and opening a dialog/popover is a `<button command commandfor>` invoker. * Reach for a script only when an action runs real logic. Two hooks exist: * * 1. **Custom invoker commands**: give the item `command="--your-action"` * and `commandfor="<target id>"`, then listen for the `command` event on * the target. The invokers polyfill re-dispatches commands in browsers * with native support, so de-dupe per task (see below). * 2. **`zazz:command-select`**: every activation bubbles this CustomEvent * from the `<ui-command>` root (`detail: { item, value }`); use it for * palette-wide concerns like analytics. */// --- Custom invoker commands ---/** * @description Binds this page's custom `--command` handlers. Handlers attach * to the command *target* (the `commandfor` element), per the Invoker * Commands contract. */function initCommandActions() { const themeTarget = document.getElementById("command-actions-target"); if (!themeTarget) return; // The invokers polyfill can deliver the same command twice in one task even // with native support: de-dupe like the toaster does. let lastEvent = null; themeTarget.addEventListener("command", (event) => { if (event === lastEvent) return; lastEvent = event; const command = event.command; switch (command) { case "--theme-toggle": document.documentElement.classList.toggle("dark"); break; case "--copy-link": void navigator.clipboard?.writeText(window.location.href); break; } });}// --- Palette-wide hook ---/** * @description Logs every command activation: swap for analytics, recents * tracking, or any cross-cutting concern. */function initCommandSelectLogging() { document.addEventListener("zazz:command-select", (event) => { const detail = event.detail; console.info("[command] selected:", detail.value); });}// --- Auto-initialization ---if (typeof window !== "undefined" && typeof document !== "undefined") { const init = () => { initCommandActions(); initCommandSelectLogging(); }; if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init); else init();}export { initCommandActions, initCommandSelectLogging };// primitives/toaster/toaster.js"use strict";var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) { if (value !== null && value !== void 0) { if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected."); var dispose, inner; if (async) { if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined."); dispose = value[Symbol.asyncDispose]; } if (dispose === void 0) { if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined."); dispose = value[Symbol.dispose]; if (async) inner = dispose; } if (typeof dispose !== "function") throw new TypeError("Object not disposable."); if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } }; env.stack.push({ value: value, dispose: dispose, async: async }); } else if (async) { env.stack.push({ async: true }); } return value;};var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) { return function (env) { function fail(e) { env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e; env.hasError = true; } var r, s = 0; function next() { while (r = env.stack.pop()) { try { if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next); if (r.dispose) { var result = r.dispose.call(r.value); if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); }); } else s |= 1; } catch (e) { fail(e); } } if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve(); if (env.hasError) throw env.error; } return next(); };})(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;});/** * @fileoverview `<ui-toaster>`: HTML web component for stacked toast notifications. * @description Light-DOM custom element that hosts a top-layer toast stack, plus * the `window.Toaster` imperative API. The stacking model (newest toast in front, * older toasts peeking behind, expand on hover, timer pause on hover/hidden tab) * is adapted from Sonner by Emil Kowalski (https://sonner.emilkowal.ski, MIT). * * The region is a `popover="manual"` element: it enters the top layer via * `showPopover()` when the first toast arrives and leaves it after the last * toast's exit transition. Toasts are plain `<li>` children, so the collapsed * stack offsets in `_toaster.css` work with normal CSS transforms. * * Fire toasts two ways: * - Declaratively, from any button, via a custom Invoker Command: * `command="--toast"` (or `--toast-success|info|warning|destructive`) with * `commandfor="<region id>"`. Toast content comes from the button's * `data-title`, `data-description`, `data-variant`, `data-duration`, and * `data-close-button` attributes. * - Imperatively: `window.Toaster.toast({ title, description, variant, … })` * and the `.success()/.info()/.warning()/.error()` shorthands. * * Region attributes: * - `data-position`: `top-start | top-center | top-end | bottom-start | * bottom-center | bottom-end` (logical; default `bottom-end`). * * @see https://developer.mozilla.org/en-US/docs/Web/API/Popover_API * @see https://developer.mozilla.org/en-US/docs/Web/API/Invoker_Commands_API * * @example * <ui-toaster class="ui-toaster" id="toaster" popover="manual"></ui-toaster> * <button commandfor="toaster" command="--toast" data-title="Saved">Save</button> */import { computed, effect, state } from "../../base/signals.js";import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";// `using` compiles (target ES2022) to try/finally helpers that read this// well-known symbol at runtime; engines without native Explicit Resource// Management (Safari) don't define it, so give them a local stand-in.Symbol.dispose ??= Symbol("Symbol.dispose");// --- Constants ---/** Default toast lifetime in milliseconds. */const TOAST_LIFETIME = 4000;/** Maximum number of toasts shown in the collapsed stack. */const VISIBLE_TOASTS = 3;/** Safety net for node removal when no exit `transitionend` fires. */const EXIT_FALLBACK_MS = 600;const VARIANTS = ["success", "info", "warning", "destructive"];// --- Icons (adapted from Sonner's assets.tsx (MIT, Emil Kowalski)) ---const ICONS = { success: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd"/></svg>', info: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd"/></svg>', warning: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z" clip-rule="evenodd"/></svg>', destructive: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-8-5a.75.75 0 01.75.75v4.5a.75.75 0 01-1.5 0v-4.5A.75.75 0 0110 5zm0 10a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd"/></svg>', close: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',};// --- Measurement ---/** * @description Unclamps every toast's block-size for a batch measurement (the * inline style beats the collapsed block-size rule) and returns a disposer * that restores the clamp. Bind it with `using` so the restore is guaranteed * at scope exit, even if a measure in between throws. * * @param toasts - The live toasts to unclamp. * @returns A `Disposable` that removes the inline block-size again. * @private */function unclampForMeasure(toasts) { for (const toast of toasts) toast.style.blockSize = "auto"; return { [Symbol.dispose]() { for (const toast of toasts) toast.style.removeProperty("block-size"); }, };}// --- Stack math (pure) ---/** * @description Computes the collapsed-stack placement for every toast from the * measured heights alone (oldest first, matching DOM order). Pure: the * measure step feeds it and an effect writes the results to the DOM, so this * is the unit-testable core of the stacking model. * * @param heights - Natural toast heights in px, oldest first. * @returns Per-toast layout (same order) and the front toast's height. */function computeStackLayout(heights) { const count = heights.length; const toasts = Array.from({ length: count }); let heightsBefore = 0; for (let i = count - 1; i >= 0; i--) { const stackIndex = count - 1 - i; // 0 = front (newest, last in DOM) toasts[i] = { stackIndex, offsetPx: heightsBefore, zIndex: count - stackIndex, front: stackIndex === 0, visible: stackIndex < VISIBLE_TOASTS, }; heightsBefore += heights[i]; } return { toasts, frontToastHeightPx: count > 0 ? heights[count - 1] : null };}// --- <ui-toaster> element ---/** * @class * @description Hosts the toast stack: builds toast markup, maintains the * collapsed-stack CSS custom properties, runs auto-dismiss timers, and shows or * hides the `popover="manual"` region as toasts come and go. */class UiToaster extends ZazzElement { #timers = new Map(); #counter = 0; #resizeFrame = 0; #handledCommand = null; #collapseTimer = 0; // Signal state: DOM events and observers write in; computed holds the pure // derivations; effects (bound in connectedCallback) write back to the DOM. /** Whether the stack is expanded (hover/focus). */ #expanded = state(false); /** Whether the tab is hidden (mirrors `document.hidden`). */ #hidden = state(typeof document !== "undefined" ? document.hidden : false); /** Timers run only while neither expanded nor hidden. */ #paused = computed(() => this.#expanded.get() || this.#hidden.get()); /** Measured stack (oldest first): written by `#reindex`'s measure pass. */ #stack = state([]); /** Pure placement derived from the measured heights. */ #layout = computed(() => computeStackLayout(this.#stack.get().map((entry) => entry.height))); setup(signal) { // Region baseline: manual popover (no light dismiss), reachable landmark. if (!this.hasAttribute("popover")) this.setAttribute("popover", "manual"); if (!this.hasAttribute("role")) this.setAttribute("role", "region"); if (!this.hasAttribute("aria-label")) this.setAttribute("aria-label", "Notifications"); if (!this.hasAttribute("tabindex")) this.setAttribute("tabindex", "-1"); // The live region must exist before toasts are inserted so additions announce. if (!this.querySelector('[data-slot~="toaster-list"]')) { const list = document.createElement("ol"); list.setAttribute("data-slot", "toaster-list"); list.setAttribute("aria-live", "polite"); list.setAttribute("aria-relevant", "additions text"); list.setAttribute("aria-atomic", "false"); this.append(list); } this.addEventListener("command", (event) => this.#onCommand(event), { signal }); // Hover/focus expands the stack; expanded (or hidden tab) pauses timers. // Leave events only *request* a collapse: dismissal animations and node // removal fire spurious mouseleave/focusout while the pointer never moved, // so the collapse is verified against real hover/focus state first. this.addEventListener("mouseenter", () => this.#setExpanded(true), { signal }); this.addEventListener("mouseleave", () => this.#requestCollapse(), { signal }); this.addEventListener("focusin", () => this.#setExpanded(true), { signal }); this.addEventListener("focusout", () => this.#requestCollapse(), { signal }); document.addEventListener("visibilitychange", () => this.#hidden.set(document.hidden), { signal, }); // Viewport resizes rewrap toast text: remeasure the stack. window.addEventListener("resize", () => { cancelAnimationFrame(this.#resizeFrame); this.#resizeFrame = requestAnimationFrame(() => this.#reindex()); }, { signal }); // Output effects (disposed by the same controller as the listeners): // one owns the expanded attribute, one owns pausing/resuming the timers, // one writes the computed stack placement to the DOM. effect(() => { this.dataset.expanded = String(this.#expanded.get()); }, { signal }); effect(() => { if (this.#paused.get()) { this.#pauseTimers(); } else { this.#resumeTimers(); } }, { signal }); effect(() => { const entries = this.#stack.get(); const { toasts, frontToastHeightPx } = this.#layout.get(); for (let i = 0; i < entries.length; i++) { const { node, height } = entries[i]; const layout = toasts[i]; node.dataset.front = String(layout.front); node.dataset.visible = String(layout.visible); node.style.zIndex = String(layout.zIndex); node.style.setProperty("--toasts-before", String(layout.stackIndex)); node.style.setProperty("--initial-height", `${height}px`); node.style.setProperty("--offset", `calc(${layout.offsetPx}px + var(--ui-toaster-gap) * ${layout.stackIndex})`); } if (frontToastHeightPx !== null) { this.style.setProperty("--front-toast-height", `${frontToastHeightPx}px`); } }, { signal }); } teardown() { cancelAnimationFrame(this.#resizeFrame); window.clearTimeout(this.#collapseTimer); for (const timer of this.#timers.values()) clearTimeout(timer.timeoutId); this.#timers.clear(); } // --- Public API --- /** * @description Adds a toast to this region and shows the region if needed. * * @param options - Toast content and behavior. * @returns The toast id (usable with `dismiss()`). */ addToast(options = {}) { const id = `toast-${++this.#counter}`; const list = this.#list(); // Show the popover before inserting so the insertion is announced and the // toast's @starting-style enter transition runs inside an open region. this.#showRegion(); list.append(this.#buildToast(options, id)); this.#reindex(); this.#startTimer(id, options.duration ?? TOAST_LIFETIME); return id; } /** * @description Dismisses one toast by id, or every toast when omitted. * * @param id - Toast id returned by `addToast()`. */ dismiss(id) { if (id === undefined) { this.dismissAll(); return; } const toast = this.#list().querySelector(`[data-toast-id="${CSS.escape(id)}"]`); if (!(toast instanceof HTMLElement) || toast.dataset.removed === "true") return; const timer = this.#timers.get(id); if (timer) clearTimeout(timer.timeoutId); this.#timers.delete(id); toast.dataset.removed = "true"; // Keyboard users keep their place: hand focus to the next toast before this // one goes. A mouse click's incidental focus is left to drop to <body>; // holding it would pin the stack expanded after the pointer leaves, and // #requestCollapse's :hover check already keeps the stack open meanwhile. const active = document.activeElement; if (active instanceof HTMLElement && toast.contains(active) && active.matches(":focus-visible")) { this.#toasts() .filter((t) => t.dataset.removed !== "true") .at(-1) ?.focus({ preventScroll: true }); } this.#reindex(); this.#finalizeRemoval(toast); } /** * @description Dismisses every toast in this region. */ dismissAll() { for (const toast of this.#toasts()) { const toastId = toast.dataset.toastId; if (toastId) this.dismiss(toastId); } } // --- Invoker Commands --- /** * @description Handles the custom `--toast` Invoker Command fired by buttons * with `commandfor` pointing at this region. `--toast-success` (etc.) sets the * variant; everything else comes from the invoker's `data-*` attributes. * * @param event - The command event. */ #onCommand(event) { const command = event.command; if (typeof command !== "string" || !command.startsWith("--toast")) return; // The invokers polyfill re-dispatches commands even in browsers with a // native CommandEvent, so one click can deliver the same command twice in // the same task. Handle the first and drop the same-task duplicate. const handled = this.#handledCommand; if (handled && handled.source === event.source && handled.command === command) return; this.#handledCommand = { source: event.source, command }; window.setTimeout(() => { this.#handledCommand = null; }, 0); const source = event.source; const options = {}; const suffix = command.slice("--toast-".length); if (VARIANTS.includes(suffix)) { options.variant = suffix; } if (source instanceof HTMLElement) { const { title, description, variant, duration, closeButton } = source.dataset; if (title) options.title = title; if (description) options.description = description; if (!options.variant && variant && VARIANTS.includes(variant)) { options.variant = variant; } if (duration !== undefined) { // A duration is only ever a number: parse it as one rather than running // it through the polymorphic attribute parser and narrowing after. // NaN-guarded rather than finite-guarded: `Infinity` is a documented // value (persist until dismissed). Blank strings coerce to 0, so they // are rejected before Number() sees them. const parsed = duration.trim() === "" ? Number.NaN : Number(duration); if (!Number.isNaN(parsed)) options.duration = parsed; } if (closeButton !== undefined) options.closeButton = closeButton !== "false"; } this.addToast(options); } // --- Toast construction --- /** * @description Builds a toast `<li>`: status icon, title/description, and the * optional action and close buttons. Text is set via `textContent`. * * @param options - Toast content and behavior. * @param id - The generated toast id. * @returns The toast element (not yet inserted). */ #buildToast(options, id) { const toast = document.createElement("li"); toast.setAttribute("data-slot", "toaster-toast"); toast.dataset.toastId = id; toast.tabIndex = 0; if (options.variant) toast.dataset.variant = options.variant; if (options.variant) { const icon = document.createElement("span"); icon.setAttribute("data-slot", "toaster-icon"); icon.setAttribute("aria-hidden", "true"); icon.innerHTML = ICONS[options.variant]; toast.append(icon); } const content = document.createElement("div"); content.setAttribute("data-slot", "toaster-content"); if (options.title) { const title = document.createElement("div"); title.setAttribute("data-slot", "toaster-title"); title.textContent = options.title; content.append(title); } if (options.description) { const description = document.createElement("div"); description.setAttribute("data-slot", "toaster-description"); description.textContent = options.description; content.append(description); } toast.append(content); const action = options.action; if (action) { const button = document.createElement("button"); button.type = "button"; button.className = "ui-button"; button.setAttribute("data-slot", "toaster-action"); button.dataset.size = "sm"; button.textContent = action.label; button.addEventListener("click", (event) => { action.onClick?.(event); if (!event.defaultPrevented) this.dismiss(id); }); toast.append(button); } if (options.closeButton !== false) { const close = document.createElement("button"); close.type = "button"; close.className = "ui-button"; close.setAttribute("data-slot", "toaster-close"); close.dataset.variant = "ghost"; close.dataset.size = "icon-sm"; close.setAttribute("aria-label", "Close notification"); close.innerHTML = ICONS.close; close.addEventListener("click", () => this.dismiss(id)); toast.append(close); } return toast; } // --- Stack math --- /** * @description Remeasures the stack: the measure half of the stacking * model. Batch-reads every live toast's natural height and writes the * result into `#stack`; `#layout` derives the placement purely * (`computeStackLayout`) and the stack effect writes the CSS custom * properties `_toaster.css` reads (`--toasts-before`, `--offset`, * `--initial-height`, `--front-toast-height`) plus `data-front`, * `data-visible`, and z-index. DOM order is chronological; the last child * is the front (newest) toast. */ #reindex() { const env_1 = { stack: [], error: void 0, hasError: false }; try { const toasts = this.#toasts().filter((toast) => toast.dataset.removed !== "true"); // Batch-measure with heights unclamped, restored at scope exit: one // layout pass, no visible change (the stack effect is microtask-batched, // so its DOM writes land after the restore). const _measure = __addDisposableResource(env_1, unclampForMeasure(toasts), false); const entries = toasts.map((toast) => ({ node: toast, height: toast.offsetHeight })); this.#stack.set(entries); } catch (e_1) { env_1.error = e_1; env_1.hasError = true; } finally { __disposeResources(env_1); } } /** * @returns The toast list (created in `connectedCallback`). */ #list() { let list = this.querySelector('[data-slot~="toaster-list"]'); if (!(list instanceof HTMLOListElement)) { list = document.createElement("ol"); list.setAttribute("data-slot", "toaster-list"); this.append(list); } return list; } /** * @returns All toast elements, oldest first (DOM order). */ #toasts() { return Array.from(this.#list().children) .filter((child) => child instanceof HTMLElement) .filter((child) => child.matches('[data-slot~="toaster-toast"]')); } // --- Expand / collapse --- /** * @description Expands or collapses the stack by writing the signal state. * The attribute write and the timer pause/resume (matching Sonner) are owned * by the effects in `connectedCallback`: no caller has to remember them. * * @param expanded - Whether the stack is expanded. */ #setExpanded(expanded) { if (expanded) window.clearTimeout(this.#collapseTimer); this.#expanded.set(expanded); } /** * @description Collapses the stack only if the user has really left it. * Waits a beat, then checks actual hover and focus state: dismissals fire * spurious mouseleave/focusout (exit transforms, node removal, focus drops) * that a raw event handler would mistake for the pointer leaving. */ #requestCollapse() { window.clearTimeout(this.#collapseTimer); this.#collapseTimer = window.setTimeout(() => { const activeElement = document.activeElement; const focusWithin = activeElement instanceof Node && this.contains(activeElement); if (!this.matches(":hover") && !focusWithin) this.#setExpanded(false); }, 100); } // --- Timers --- /** * @description Registers a toast's auto-dismiss timer and schedules it unless * the stack is currently paused (expanded or hidden tab). * * @param id - Toast id. * @param duration - Lifetime in ms; `Infinity` skips the timer. */ #startTimer(id, duration) { if (duration === Infinity || Number.isNaN(duration)) return; const timer = { remaining: duration, startedAt: 0, timeoutId: 0 }; this.#timers.set(id, timer); if (!this.#paused.get()) this.#schedule(id, timer); } /** * @description Arms a timer's timeout for its remaining lifetime. * * @param id - Toast id. * @param timer - The timer record. */ #schedule(id, timer) { timer.startedAt = Date.now(); timer.timeoutId = window.setTimeout(() => this.dismiss(id), timer.remaining); } /** * @description Pauses all running timers, banking each one's remaining time. */ #pauseTimers() { for (const timer of this.#timers.values()) { if (!timer.timeoutId) continue; clearTimeout(timer.timeoutId); timer.timeoutId = 0; timer.remaining = Math.max(0, timer.remaining - (Date.now() - timer.startedAt)); } } /** * @description Resumes paused timers (no-op while still expanded or hidden). */ #resumeTimers() { if (this.#paused.get()) return; for (const [id, timer] of this.#timers) { if (!timer.timeoutId) this.#schedule(id, timer); } } // --- Region show/hide --- /** * @description Puts the region on the top layer if it isn't already. */ #showRegion() { try { if (!this.matches(":popover-open, .\\:popover-open")) this.showPopover(); } catch { // Older engines without the Popover API: the region stays a fixed-position // element, which still renders (just not on the top layer). } } /** * @description Removes the region from the top layer. */ #hideRegion() { try { if (this.matches(":popover-open, .\\:popover-open")) this.hidePopover(); } catch { // See #showRegion. } } /** * @description Removes a dismissed toast after its exit transition (with a * timeout safety net), then hides the region once the stack is empty. * * @param toast - The toast marked `data-removed="true"`. */ #finalizeRemoval(toast) { let done = false; const remove = () => { if (done) return; done = true; toast.remove(); if (this.#toasts().length === 0) { this.#setExpanded(false); this.#hideRegion(); } }; toast.addEventListener("transitionend", (event) => { if (event.target === toast) remove(); }); // Safety net for when no transitionend fires (reduced motion → duration 0). // Scaled to the computed duration so retuned --ui-toaster-transition-duration // themes aren't yanked out mid-exit. const duration = getComputedStyle(toast) .transitionDuration.split(",") .reduce((max, value) => Math.max(max, Number.parseFloat(value) || 0), 0); window.setTimeout(remove, Math.max(EXIT_FALLBACK_MS, duration * 1000 + 100)); }}// --- Imperative API ---/** * @description Resolves a target region from an id, element, or the document. * * @param region - Region id or element. * @returns The region, or `null` when none exists. * @private */function resolveRegion(region) { const node = region instanceof Element ? region : typeof region === "string" ? document.getElementById(region) : document.querySelector("ui-toaster"); if (node instanceof UiToaster) return node; console.warn('Toaster: no <ui-toaster> found. Add `<ui-toaster class="ui-toaster" popover="manual"></ui-toaster>` to the page.'); return null;}/** * @description Normalizes the `toast("message")` string shorthand. * * @param options - Options object or title string. * @returns The options object. * @private */function toOptions(options) { if (typeof options === "string") return { title: options }; return options ?? {};}/** * @namespace Toaster * @description Imperative toast API. Requires a `<ui-toaster>` in the page: * the region is never auto-created (HTML-first, like every Zazz component). * * @property toast - Shows a toast; returns its id. * @property success - Success shorthand. * @property info - Info shorthand. * @property warning - Warning shorthand. * @property error - Destructive shorthand. * @property dismiss - Dismisses one toast, or all when omitted. */const Toaster = { /** * @description Shows a toast in the target (or first) region. * * @param options - Options object or title string. * @returns The toast id, or `null` when no region exists. */ toast(options) { const resolved = toOptions(options); const region = resolveRegion(resolved.region); return region ? region.addToast(resolved) : null; }, /** * @description Shows a success toast. * * @param message - Toast title. * @param options - Additional options. * @returns The toast id, or `null` when no region exists. */ success(message, options) { return Toaster.toast({ ...options, title: message, variant: "success" }); }, /** * @description Shows an info toast. * * @param message - Toast title. * @param options - Additional options. * @returns The toast id, or `null` when no region exists. */ info(message, options) { return Toaster.toast({ ...options, title: message, variant: "info" }); }, /** * @description Shows a warning toast. * * @param message - Toast title. * @param options - Additional options. * @returns The toast id, or `null` when no region exists. */ warning(message, options) { return Toaster.toast({ ...options, title: message, variant: "warning" }); }, /** * @description Shows a destructive/error toast. * * @param message - Toast title. * @param options - Additional options. * @returns The toast id, or `null` when no region exists. */ error(message, options) { return Toaster.toast({ ...options, title: message, variant: "destructive" }); }, /** * @description Dismisses a toast by id in any region, or every toast everywhere. * * @param id - Toast id returned by `toast()`. */ dismiss(id) { for (const region of document.querySelectorAll("ui-toaster")) { if (region instanceof UiToaster) region.dismiss(id); } },};defineZazzElement("ui-toaster", UiToaster);// Attach the imperative toast API to window (the documented public surface app// authors call), then export for module consumers.if (typeof window !== "undefined") { window.Toaster = Toaster;}// computeStackLayout is exported for unit tests only; not part of the// documented public API (window.Toaster is the surface app authors use).export { Toaster, UiToaster, computeStackLayout };/** * command.css: Command (ui-command | .ui-command, [data-slot~="command-panel"]) * * @layer variables, components * @requires layers.css, _variables.css, popover.css, button.css, fields.css, * dialog.css, menu.css, select.css (--ui-option-*), kbd.css (--ui-kbd-group-*) * @uses popovertarget + [popover="auto"]: dropdown surface, native light dismiss * @uses <dialog class="ui-dialog"> + command="show-modal": focused surface * @uses anchor-name, anchor-scope, position-anchor: tether the popover form * @uses :has(): group + empty-state visibility without JavaScript * @tokens --ui-command-* (@layer variables); items alias --ui-option-* * @see command.ts: ranking, hotkeys, and activation behavior */@layer variables { :root { --ui-command-min-inline-size: var(--step-96); --ui-command-max-block-size: var(--step-96); --ui-command-option-gap: var(--ui-field-option-gap); --ui-command-input-padding: var(--step-3); --ui-command-input-border: 1px solid var(--border); --ui-command-group-label-padding: var(--step-2) var(--step-2) var(--step-1); --ui-command-footer-padding: var(--step-2); --ui-command-footer-border: 1px solid var(--border); --ui-command-shadow: var(--ui-menu-shadow); }}@layer zazz.components { /* =========================================================================== COMMAND: palette panel (popover or dialog) with the input inside - The popover form rides popover.css's surface + placement; the dialog form rides dialog.css. This file shapes the interior: borderless input over a hairline, scrollable list, eyebrow group labels, hint footer. =========================================================================== */ :where(ui-command, .ui-command) { anchor-scope: --ui-command-trigger; display: inline-flex; } :where(ui-command, .ui-command) > :where([popovertarget], [command]) { anchor-name: --ui-command-trigger; } [data-slot~="command-panel"] { /* flex-direction stays ungated so the close fade (display held by the allow-discrete transition) keeps the column layout to the last frame */ flex-direction: column; min-inline-size: var(--ui-command-min-inline-size); box-shadow: var(--ui-command-shadow); } /* Only display is gated; unconditional it would defeat the UA's display: none on closed popovers and dialogs */ [data-slot~="command-panel"]:where(:popover-open, .\:popover-open, [open]) { display: flex; } [data-slot~="command-panel"]:where([popover]) { position-anchor: --ui-command-trigger; padding: 0; } /* Dialog form: the palette owns its interior, edge to edge */ [data-slot~="command-panel"]:where(dialog) { --ui-dialog-width: min(var(--breakpoint-sm), 100% - var(--gutters) * 2); padding: 0; } /* Search input: borderless over a hairline; the panel is the surface */ [data-slot~="command-header"] { display: flex; align-items: center; border-block-end: var(--ui-command-input-border); } [data-slot~="command-input"] { flex: 1; min-inline-size: 0; padding: var(--ui-command-input-padding); font: inherit; font-size: var(--ui-field-font-size); color: inherit; background: none; border: none; outline: none; } [data-slot~="command-list"] { display: flex; flex-direction: column; gap: var(--ui-command-option-gap); max-block-size: var(--ui-command-max-block-size); padding: var(--ui-popover-padding); margin: 0; overflow: auto; list-style: none; } [data-slot~="command-group"] { display: flex; flex-direction: column; gap: var(--ui-command-option-gap); } [data-slot~="command-group-label"] { padding: var(--ui-command-group-label-padding); /* Score ranking writes order -1000..0 on sibling items; keep the label first */ order: -1001; } [data-slot~="command-item"] { cursor: pointer; } [data-slot~="command-item"][data-highlighted] { --ui-button-background: var(--ui-button-background--hover); --ui-button-foreground: var(--ui-button-foreground--hover); } /* Shortcut hint: a lone <kbd> or a <ui-kbd-group> run pushes to the end; spacing between keys inside a run is the group's --ui-kbd-group-gap. */ [data-slot~="command-item"] > :where(kbd, ui-kbd-group, .ui-kbd-group) { margin-inline-start: auto; } [data-slot~="command-footer"] { display: flex; align-items: center; gap: var(--step-2); padding: var(--ui-command-footer-padding); border-block-start: var(--ui-command-footer-border); color: var(--muted-foreground); font-size: var(--font-size-xs); } [data-slot~="command-group"]:not(:has([data-slot~="command-item"]:not([hidden]))) { display: none; } [data-slot~="command-empty"] { display: none; padding: var(--step-2); } [data-slot~="command-panel"]:not(:has([data-slot~="command-item"]:not([hidden]))) [data-slot~="command-empty"] { display: block; }}API
| Attribute | Target | Values |
|---|---|---|
data-command-hotkey | Root | Global toggle shortcut, e.g. mod+k |
data-hotkey | [data-slot="command-item"] | Global accelerator that activates the item |
data-stay-open | [data-slot="command-item"] | Keep the panel open after activation |
data-sort | Root | document: opt out of score ranking (default: score) |
command / commandfor | [data-slot="command-item"] | Native or custom (--…) invoker action |
data-value / data-keywords | [data-slot="command-item"] | Match text / extra match targets |
data-side / data-align | [data-slot="command-panel"][popover] | Placement (popover form only) |