Autocomplete
An input that suggests options as you type, ranked by match quality.
A <ui-autocomplete> wraps a .ui-input and an anchored suggestion panel (popover="manual"). The input holds the form value (free text allowed) and typing filters the panel options using the cmdk ranking algorithm. Arrow keys move the highlight without moving focus out of the input (aria-activedescendant). Pressing Enter or clicking commits a suggestion into the input. Groups hide automatically when all their options filter out, and the empty slot appears when nothing matches. Without JavaScript, the markup renders as a regular text input.
Omit the popover attribute on the panel for an inline, always-open list (no floating surface or dismissal; filtering unchanged).
Default
<div class="ui-field max-w-xl"> <label data-slot="field-label" for="ac-fruit">Fruit</label> <ui-autocomplete> <input class="ui-input" id="ac-fruit" name="fruit" type="text" placeholder="Search fruits..." role="combobox" aria-expanded="false" aria-autocomplete="list" autocomplete="off" /> <div data-slot="autocomplete-panel" data-side="bottom" data-align="start" popover="manual"> <ul role="listbox" data-slot="autocomplete-list" aria-label="Fruit suggestions"> <li role="option" data-slot="autocomplete-item" class="ui-button justify-start" data-variant="ghost" data-value="Apple" > Apple </li> <li role="option" data-slot="autocomplete-item" class="ui-button justify-start" data-variant="ghost" data-value="Banana" > Banana </li> <li role="option" data-slot="autocomplete-item" class="ui-button justify-start" data-variant="ghost" data-value="Blueberry" > Blueberry </li> <li role="option" data-slot="autocomplete-item" class="ui-button justify-start" data-variant="ghost" data-value="Cherry" > Cherry </li> <li role="option" data-slot="autocomplete-item" class="ui-button justify-start" data-variant="ghost" data-value="Grape" > Grape </li> </ul> <p data-slot="autocomplete-empty" class="text-muted-foreground">No results.</p> </div> </ui-autocomplete></div>// 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/autocomplete/autocomplete.js"use strict";/** * @fileoverview `<ui-autocomplete>`: an input that suggests options as you type. * @description Light-DOM custom element on the shared typeahead engine * (`base/typeahead.ts`). The visible input is the form value (free text is * allowed) and the anchored `popover="manual"` panel suggests matches ranked * by the vendored cmdk scorer. Committing a suggestion (Enter or click) fills * the input; without JavaScript the markup degrades to a plain `.ui-input`. * * Attributes on the root: * - `data-sort="score"`: re-rank visually by match score (default: DOM order). * - `data-min-length="<n>"`: query length before the panel opens (default 0). * * Parts: `autocomplete-panel` (popover="manual"), `autocomplete-list` * ([role="listbox"]), `autocomplete-item` ([role="option"], `data-value`, * optional `data-keywords`), `autocomplete-group` / `autocomplete-group-label`, * `autocomplete-empty`. */import { TypeaheadElement } from "../../base/typeahead.js";import { defineZazzElement } from "../../base/zazz-element.js";class UiAutocomplete extends TypeaheadElement { slotPrefix = "autocomplete"; /** * @description Committing a suggestion fills the visible input (which is * the form value), closes the panel, and returns to typing position. * * @param item - The committed option. */ commit(item, _source) { const input = this.searchInput; if (!input) return; input.value = this.itemValue(item); this.query.set(input.value); this.open.set(false); input.focus(); input.dispatchEvent(new Event("change", { bubbles: true })); }}defineZazzElement("ui-autocomplete", UiAutocomplete);export { UiAutocomplete };/** * autocomplete.css — Autocomplete (ui-autocomplete | .ui-autocomplete, [data-slot~="autocomplete-panel"]) * * @layer variables, components * @requires layers.css, _variables.css, popover.css, button.css, fields.css, * select.css (--ui-option-*), input.css * @uses [popover="manual"] — panel opened/closed by autocomplete.js * @uses anchor-name, anchor-scope, position-anchor — tether panel to the input * @uses anchor-size(width) — match panel width to the input (@supports gated) * @uses :has() — group + empty-state visibility without JavaScript * @uses data-side / data-align — maps to --ui-popover-position-* in popover.css * @tokens --ui-autocomplete-* (@layer variables); items alias --ui-option-* * @see autocomplete.ts — filtering, ranking, and keyboard behavior */@layer variables { :root { --ui-autocomplete-panel-max-block-size: var(--step-72); --ui-autocomplete-shadow: var(--ui-menu-shadow); }}@layer zazz.components { /* =========================================================================== AUTOCOMPLETE — input + anchored suggestion panel - The input is a plain .ui-input and the no-JS baseline; the panel is a manual popover the script owns, riding popover.css's surface + placement. - Items are ghost buttons (the --ui-option-* alias family), so a select's options and an autocomplete's suggestions read identically. =========================================================================== */ :where(ui-autocomplete, .ui-autocomplete) { anchor-scope: --ui-autocomplete-trigger; display: block; inline-size: 100%; } :where(ui-autocomplete, .ui-autocomplete) input:where([role="combobox"]) { anchor-name: --ui-autocomplete-trigger; } [data-slot~="autocomplete-panel"] { position-anchor: --ui-autocomplete-trigger; /* flex-direction stays ungated so the close fade keeps the column layout */ flex-direction: column; max-block-size: var(--ui-autocomplete-panel-max-block-size); overflow: auto; box-shadow: var(--ui-autocomplete-shadow); } /* Only display is gated — see popover.css: closed popovers stay display: none */ [data-slot~="autocomplete-panel"]:where(:popover-open, .\:popover-open) { display: flex; } /* Inline variant — no [popover]: the list renders in flow, always open, without the floating surface chrome (autocomplete.js treats it as such) */ [data-slot~="autocomplete-panel"]:not([popover]) { display: flex; box-shadow: none; } @supports (inline-size: anchor-size(width)) { [data-slot~="autocomplete-panel"] { min-inline-size: anchor-size(width); } } [data-slot~="autocomplete-list"] { display: flex; flex-direction: column; gap: var(--step-px); margin: 0; padding: 0; list-style: none; } [data-slot~="autocomplete-group"] { display: flex; flex-direction: column; gap: var(--step-px); } /* Score ranking writes order -1000..0 on sibling items — keep the label first */ [data-slot~="autocomplete-group-label"] { order: -1001; } [data-slot~="autocomplete-item"] { cursor: pointer; } /* Keyboard highlight mirrors hover — variants only reassign tokens */ [data-slot~="autocomplete-item"][data-highlighted] { --ui-button-background: var(--ui-button-background--hover); --ui-button-foreground: var(--ui-button-foreground--hover); } /* A group with no visible items disappears, label and all — no JS */ [data-slot~="autocomplete-group"]:not(:has([data-slot~="autocomplete-item"]:not([hidden]))) { display: none; } /* Empty state appears only when nothing matches */ [data-slot~="autocomplete-empty"] { display: none; padding: var(--step-2); } [data-slot~="autocomplete-panel"]:not(:has([data-slot~="autocomplete-item"]:not([hidden]))) [data-slot~="autocomplete-empty"] { display: block; }}Groups and score ranking
data-sort="score" re-ranks visible options by match quality using CSS order without altering the DOM tree. data-keywords adds extra search keywords to an option.
<div class="ui-field max-w-xl"> <label data-slot="field-label" for="ac-produce">Produce</label> <ui-autocomplete data-sort="score"> <input class="ui-input" id="ac-produce" name="produce" type="text" placeholder="Search produce..." role="combobox" aria-expanded="false" aria-autocomplete="list" autocomplete="off" /> <div data-slot="autocomplete-panel" data-side="bottom" data-align="start" popover="manual"> <ul role="listbox" data-slot="autocomplete-list" aria-label="Produce suggestions"> <li role="group" data-slot="autocomplete-group" aria-labelledby="ac-group-fruit"> <span class="font-strong text-eyebrow text-muted-foreground p-xs" data-slot="autocomplete-group-label" id="ac-group-fruit" >Fruit</span > <div role="option" data-slot="autocomplete-item" class="ui-button justify-start" data-variant="ghost" data-value="Apple" data-keywords="fruit red" > Apple </div> <div role="option" data-slot="autocomplete-item" class="ui-button justify-start" data-variant="ghost" data-value="Cherry" data-keywords="fruit red" > Cherry </div> </li> <li role="group" data-slot="autocomplete-group" aria-labelledby="ac-group-veg"> <span class="font-strong text-eyebrow text-muted-foreground p-xs" data-slot="autocomplete-group-label" id="ac-group-veg" >Vegetables</span > <div role="option" data-slot="autocomplete-item" class="ui-button justify-start" data-variant="ghost" data-value="Carrot" data-keywords="vegetable orange" > Carrot </div> <div role="option" data-slot="autocomplete-item" class="ui-button justify-start" data-variant="ghost" data-value="Cauliflower" data-keywords="vegetable white" > Cauliflower </div> </li> </ul> <p data-slot="autocomplete-empty" class="text-muted-foreground">No results.</p> </div> </ui-autocomplete></div>// 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/autocomplete/autocomplete.js"use strict";/** * @fileoverview `<ui-autocomplete>`: an input that suggests options as you type. * @description Light-DOM custom element on the shared typeahead engine * (`base/typeahead.ts`). The visible input is the form value (free text is * allowed) and the anchored `popover="manual"` panel suggests matches ranked * by the vendored cmdk scorer. Committing a suggestion (Enter or click) fills * the input; without JavaScript the markup degrades to a plain `.ui-input`. * * Attributes on the root: * - `data-sort="score"`: re-rank visually by match score (default: DOM order). * - `data-min-length="<n>"`: query length before the panel opens (default 0). * * Parts: `autocomplete-panel` (popover="manual"), `autocomplete-list` * ([role="listbox"]), `autocomplete-item` ([role="option"], `data-value`, * optional `data-keywords`), `autocomplete-group` / `autocomplete-group-label`, * `autocomplete-empty`. */import { TypeaheadElement } from "../../base/typeahead.js";import { defineZazzElement } from "../../base/zazz-element.js";class UiAutocomplete extends TypeaheadElement { slotPrefix = "autocomplete"; /** * @description Committing a suggestion fills the visible input (which is * the form value), closes the panel, and returns to typing position. * * @param item - The committed option. */ commit(item, _source) { const input = this.searchInput; if (!input) return; input.value = this.itemValue(item); this.query.set(input.value); this.open.set(false); input.focus(); input.dispatchEvent(new Event("change", { bubbles: true })); }}defineZazzElement("ui-autocomplete", UiAutocomplete);export { UiAutocomplete };/** * autocomplete.css — Autocomplete (ui-autocomplete | .ui-autocomplete, [data-slot~="autocomplete-panel"]) * * @layer variables, components * @requires layers.css, _variables.css, popover.css, button.css, fields.css, * select.css (--ui-option-*), input.css * @uses [popover="manual"] — panel opened/closed by autocomplete.js * @uses anchor-name, anchor-scope, position-anchor — tether panel to the input * @uses anchor-size(width) — match panel width to the input (@supports gated) * @uses :has() — group + empty-state visibility without JavaScript * @uses data-side / data-align — maps to --ui-popover-position-* in popover.css * @tokens --ui-autocomplete-* (@layer variables); items alias --ui-option-* * @see autocomplete.ts — filtering, ranking, and keyboard behavior */@layer variables { :root { --ui-autocomplete-panel-max-block-size: var(--step-72); --ui-autocomplete-shadow: var(--ui-menu-shadow); }}@layer zazz.components { /* =========================================================================== AUTOCOMPLETE — input + anchored suggestion panel - The input is a plain .ui-input and the no-JS baseline; the panel is a manual popover the script owns, riding popover.css's surface + placement. - Items are ghost buttons (the --ui-option-* alias family), so a select's options and an autocomplete's suggestions read identically. =========================================================================== */ :where(ui-autocomplete, .ui-autocomplete) { anchor-scope: --ui-autocomplete-trigger; display: block; inline-size: 100%; } :where(ui-autocomplete, .ui-autocomplete) input:where([role="combobox"]) { anchor-name: --ui-autocomplete-trigger; } [data-slot~="autocomplete-panel"] { position-anchor: --ui-autocomplete-trigger; /* flex-direction stays ungated so the close fade keeps the column layout */ flex-direction: column; max-block-size: var(--ui-autocomplete-panel-max-block-size); overflow: auto; box-shadow: var(--ui-autocomplete-shadow); } /* Only display is gated — see popover.css: closed popovers stay display: none */ [data-slot~="autocomplete-panel"]:where(:popover-open, .\:popover-open) { display: flex; } /* Inline variant — no [popover]: the list renders in flow, always open, without the floating surface chrome (autocomplete.js treats it as such) */ [data-slot~="autocomplete-panel"]:not([popover]) { display: flex; box-shadow: none; } @supports (inline-size: anchor-size(width)) { [data-slot~="autocomplete-panel"] { min-inline-size: anchor-size(width); } } [data-slot~="autocomplete-list"] { display: flex; flex-direction: column; gap: var(--step-px); margin: 0; padding: 0; list-style: none; } [data-slot~="autocomplete-group"] { display: flex; flex-direction: column; gap: var(--step-px); } /* Score ranking writes order -1000..0 on sibling items — keep the label first */ [data-slot~="autocomplete-group-label"] { order: -1001; } [data-slot~="autocomplete-item"] { cursor: pointer; } /* Keyboard highlight mirrors hover — variants only reassign tokens */ [data-slot~="autocomplete-item"][data-highlighted] { --ui-button-background: var(--ui-button-background--hover); --ui-button-foreground: var(--ui-button-foreground--hover); } /* A group with no visible items disappears, label and all — no JS */ [data-slot~="autocomplete-group"]:not(:has([data-slot~="autocomplete-item"]:not([hidden]))) { display: none; } /* Empty state appears only when nothing matches */ [data-slot~="autocomplete-empty"] { display: none; padding: var(--step-2); } [data-slot~="autocomplete-panel"]:not(:has([data-slot~="autocomplete-item"]:not([hidden]))) [data-slot~="autocomplete-empty"] { display: block; }}API
| Attribute | Target | Values |
|---|---|---|
data-sort | Root | score: rank by match (default: DOM order) |
data-min-length | Root | Query length before the panel opens (default 0) |
data-value | [data-slot="autocomplete-item"] | Match/commit text (default: text content) |
data-keywords | [data-slot="autocomplete-item"] | Space-separated extra match targets |
data-side / data-align | [data-slot="autocomplete-panel"] | Placement (popover matrix) |