Combobox
An input combined with a predefined list, filtering options like a select.
A <ui-combobox> looks like a select and filters like an autocomplete, but never submits free text. The form value lives in an authored hidden input (data-slot="combobox-value"), synced whenever the selection changes. Options match against their visible label and carry their machine value in data-value. Selected options get aria-selected and the shared option checkmark.
The visible input carries a display value, not the filter. Committing writes the option label into the input and clears the query. Reopening by clicking the control or chevron shows the full list with the committed row checked, matching Select. Opening selects that label so the first keystroke replaces it. Focus alone does not open the panel, keeping keyboard tab navigation quiet. On blur, stray text reverts to the committed label, and cleared text clears the selection.
Omit the popover attribute on the panel for the inline variant: the list renders in flow and stays open without a floating surface, chevron, or dismissal. Filtering, highlighting, and committing work identically. This variant fits inside surfaces that already float, such as a Menubar help menu.
Without JavaScript this control remains inert. When a no-JS fallback is required, use Select instead.
Default
<div class="ui-field max-w-xl"> <label data-slot="field-label" for="cb-country">Country</label> <ui-combobox> <input type="hidden" name="country" data-slot="combobox-value" value="" /> <div data-slot="combobox-control"> <input id="cb-country" type="text" placeholder="Select a country..." role="combobox" aria-expanded="false" aria-autocomplete="list" autocomplete="off" /> <button type="button" data-slot="combobox-trigger" tabindex="-1" aria-label="Show options" ></button> </div> <div data-slot="combobox-panel" data-side="bottom" data-align="start" popover="manual"> <ul role="listbox" data-slot="combobox-list" aria-label="Countries"> <li role="option" data-slot="combobox-item" class="ui-button justify-start" data-variant="ghost" data-value="au" > Australia </li> <li role="option" data-slot="combobox-item" class="ui-button justify-start" data-variant="ghost" data-value="ca" > Canada </li> <li role="option" data-slot="combobox-item" class="ui-button justify-start" data-variant="ghost" data-value="jp" > Japan </li> <li role="option" data-slot="combobox-item" class="ui-button justify-start" data-variant="ghost" data-value="nz" > New Zealand </li> <li role="option" data-slot="combobox-item" class="ui-button justify-start" data-variant="ghost" data-value="us" > United States </li> </ul> <p data-slot="combobox-empty" class="text-muted-foreground text-sm">No matches.</p> </div> </ui-combobox></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/combobox/combobox.js"use strict";/** * @fileoverview `<ui-combobox>`: an input restricted to a predefined list. * @description Light-DOM custom element on the shared typeahead engine * (`base/typeahead.ts`). A relative of select: the panel filters as you type, * but free text can never submit. The form value lives in an authored hidden * input (`data-slot="combobox-value"`), synced whenever the selection changes. * * The visible input carries a **display value, not the filter**. Committing * writes the option label into it and clears the query, so reopening shows * the full list with the committed row checked, matching `.ui-select`. * Opening selects that label so the first keystroke replaces it, and focus * alone never opens the panel (a click, the chevron, an arrow key or typing * does). On blur, stray text reverts to the committed label and cleared text * clears the selection. * * `data-variant="multiselect"` selects a set instead: committing toggles a row * and keeps the panel open, and every selected value renders as a removable * tag stamped inside the control. The tag markup is HTML, not script: author * a `<template data-slot="combobox-tag-template">` to control its classes and * structure, and this file fills in the label, the value, and the remove * button accessible name. Without one, the ui-badge default below applies. * Backspace on an empty input drops the last tag. Options carrying * `aria-selected="true"` are the single source of truth: the tags and the * hidden inputs are both derived from them in DOM order so nothing can drift. * The form submits repeated `name` pairs like `<select multiple>`: the authored * hidden input carries the first value, and stamped siblings carry the rest. * * Where a no-JS fallback matters, prefer `.ui-select` / `<ui-multiselect>`: * this control is inert without its script (the hidden input still submits a * server-set value, but no tags render). * * Attributes on the root: `data-variant="multiselect"`, `data-sort="score"`, * `data-min-length="<n>"`, `data-label-remove="Remove {label}"`. * Parts: `combobox-value` (hidden input), `combobox-control` (select-look * shell), `combobox-tag-template` (authored `<template>`), `combobox-tag` / * `combobox-tag-label` / `combobox-tag-remove` (cloned from it), * `combobox-trigger` (chevron), `combobox-panel`, `combobox-list`, * `combobox-item`, `combobox-group` / `combobox-group-label`, `combobox-empty`. */import { TypeaheadElement } from "../../base/typeahead.js";import { defineZazzElement } from "../../base/zazz-element.js";// --- Tag blueprint ---/** * The default tag markup, cloned once per selected value. Authors override it * wholesale with a `<template data-slot="combobox-tag-template">`, so restyling * a tag (other classes, an extra icon, a different component entirely) never * means editing this script. Kept as markup rather than createElement calls so * the default reads the same way the override is written. */const TAG_MARKUP = '<span class="ui-badge" data-slot="combobox-tag">' + '<span data-slot="combobox-tag-label"></span>' + '<button type="button" tabindex="-1" data-slot="combobox-tag-remove"></button>' + "</span>";let defaultTagTemplate = null;/** * @description Parses `TAG_MARKUP` once and shares the result. * * @returns The fallback tag template. * @private */function defaultTagBlueprint() { if (!defaultTagTemplate) { defaultTagTemplate = document.createElement("template"); defaultTagTemplate.innerHTML = TAG_MARKUP; } return defaultTagTemplate;}/** * @description Resolves a blur: free text never survives it. The multiselect * variant always empties the filter (its selection lives in the tags), the * single variant restores the committed label, and an emptied single-select * input clears the selection outright. * * @param typed - The input's current text. * @param committedLabel - The last committed label, empty for no selection. * @param multiselect - Whether the multiselect variant is active. * @returns The text to show and whether to clear the selection. */function resolveBlur(typed, committedLabel, multiselect) { if (multiselect) return { value: "", clear: false }; if (typed === "") return { value: "", clear: true }; return { value: committedLabel, clear: false };}/** * @description Fills the remove-button label template (`data-label-remove`). * * @param template - Template with a `{label}` placeholder. * @param label - The tag's visible label. * @returns The accessible name for that tag's remove button. */function resolveRemoveLabel(template, label) { return template.replace("{label}", label);}// --- Element ---class UiCombobox extends TypeaheadElement { slotPrefix = "combobox"; /** The input holds a committed label, so focus alone must not open the list. */ openOnFocus = false; #committedLabel = ""; #placeholder = ""; #defaultValues = []; #serialized = ""; setup(signal) { super.setup(signal); const input = this.searchInput; if (!input) return; const multiselect = this.#multiselect(); this.#placeholder = input.getAttribute("placeholder") ?? ""; if (multiselect) { this.querySelector('[data-slot~="combobox-list"]')?.setAttribute("aria-multiselectable", "true"); } // Adopt a server-rendered value no row claims yet, so `value` alone is // enough markup to seed the selection, ensuring the sync below never // silently discards it const hidden = this.#valueInput(); if (hidden !== null && hidden.value !== "" && this.#selectedItems().length === 0) { const match = this.items().find((item) => this.#itemFormValue(item) === hidden.value); match?.setAttribute("aria-selected", "true"); } // A multi-select listbox announces every row state, not just the picked ones if (multiselect) { for (const item of this.items()) { if (item.getAttribute("aria-selected") !== "true") { item.setAttribute("aria-selected", "false"); } } } // Attribute state has no defaultValue: remember the markup selection so // a form reset can restore it this.#defaultValues = this.#selectedItems().map((item) => this.#itemFormValue(item)); this.#committedLabel = multiselect ? "" : (this.#selectedLabel() ?? ""); input.value = this.#committedLabel; this.#syncSelection(false); const control = input.closest('[data-slot~="combobox-control"]'); // A select-look control is one big hit target input.addEventListener("click", () => this.#openPanel(), { signal }); control?.addEventListener("mousedown", (event) => { if (!(event.target instanceof Element) || event.target === input) return; // Nothing in the shell may steal focus from the input: the chevron and // the tag remove buttons would otherwise blur it and close the panel event.preventDefault(); input.focus(); if (!event.target.closest('[data-slot~="combobox-trigger"], [data-slot~="combobox-tag"]')) { this.#openPanel(); } }, { signal }); // Tag removal is delegated: the tags are re-stamped on every change control?.addEventListener("click", (event) => { if (!(event.target instanceof Element)) return; const remove = event.target.closest('[data-slot~="combobox-tag-remove"]'); if (!remove) return; // A template that forgets type="button" would otherwise submit the form event.preventDefault(); const value = remove.closest('[data-slot~="combobox-tag"]')?.getAttribute("data-value"); this.#deselect(this.items().find((item) => this.#itemFormValue(item) === value)); input.focus(); }, { signal }); // Chevron toggles the full, unfiltered list const trigger = this.querySelector('[data-slot~="combobox-trigger"]'); if (trigger instanceof HTMLElement) { trigger.addEventListener("click", () => { if (this.open.get()) this.open.set(false); else this.#openPanel(); }, { signal }); } // Backspace on an empty filter drops the last tag if (multiselect) { input.addEventListener("keydown", (event) => { if (event.key !== "Backspace" || input.value !== "") return; const last = this.#selectedItems().at(-1); if (!last) return; event.preventDefault(); this.#deselect(last); }, { signal }); } // No free text: on leaving, revert stray text or clear the selection this.addEventListener("focusout", (event) => { const next = event.relatedTarget; if (next instanceof Node && this.contains(next)) return; const outcome = resolveBlur(input.value, this.#committedLabel, this.#multiselect()); input.value = outcome.value; // Safe mid-close: the gated ranking effect ignores query writes while // the panel is fading out this.query.set(""); if (outcome.clear) this.#clearSelection(); }, { signal }); // A form reset restores the selection the markup shipped with (reset // applies after the event) (hidden ?? input).form?.addEventListener("reset", () => queueMicrotask(() => this.#restoreDefaults()), { signal }); } teardown() { for (const node of this.querySelectorAll('[data-slot~="combobox-tag"], [data-combobox-stamped]')) { node.remove(); } if (this.searchInput) this.searchInput.placeholder = this.#placeholder; } /** * @description Combobox items match against what the user sees (the label) * not the machine `data-value`. * * @param item - The item element. * @returns The trimmed visible label. */ itemValue(item) { return item.textContent?.trim() ?? ""; } /** * @description Escape with the panel already closed restores the committed * label (single) or drops the filter text (multiselect), avoiding a half-typed * value that a later blur would read as a cleared selection. */ clearQuery() { const input = this.searchInput; if (!input) return; input.value = this.#multiselect() ? "" : this.#committedLabel; this.query.set(""); } /** * @description Committing shows the item's label, stores its `data-value` in * the hidden input, and moves `aria-selected`. The multiselect variant * toggles the row instead and keeps the panel open so picking can continue. * * @param item - The committed option. */ commit(item, _source) { const input = this.searchInput; if (!input) return; if (this.#multiselect()) { item.setAttribute("aria-selected", String(item.getAttribute("aria-selected") !== "true")); input.value = ""; this.query.set(""); // Follow the row through the re-widened list so a second Enter toggles it // back: with an empty query every item is visible, and visual order is // DOM order (score ranking is stable at equal scores) this.activeIndex.set(this.items().indexOf(item)); this.#syncSelection(true); input.focus(); return; } this.#committedLabel = this.itemValue(item); input.value = this.#committedLabel; // The label is display text, not a filter: clearing the query is what // makes the reopened panel show the full list with this row checked this.query.set(""); for (const other of this.items()) other.removeAttribute("aria-selected"); item.setAttribute("aria-selected", "true"); this.#syncSelection(true); input.focus(); // The inline variant has no popover to close, and closing it would gate its // filtering off for good. It also never reopens, so commit is where it // selects the label (the popover variants do that in #openPanel()). if (this.panel?.hasAttribute("popover")) this.open.set(false); else input.select(); } /** * @description Whether the multiselect variant is active. * * @returns True for `data-variant="multiselect"`. * @private */ #multiselect() { return this.getAttribute("data-variant") === "multiselect"; } /** * @description Opens the panel the way a select does: the full list, the * committed row highlighted, and its label selected so the first keystroke * replaces it. `activeIndex` is written here rather than from an effect * because effects are output adapters, and writes from inside one can be dropped * until the next notification. * @private */ #openPanel() { const input = this.searchInput; if (!input || this.open.get()) return; if (this.#multiselect()) { input.value = ""; this.activeIndex.set(-1); } else { input.value = this.#committedLabel; const selected = this.#selectedItems()[0]; this.activeIndex.set(selected ? this.items().indexOf(selected) : -1); if (input.value !== "") input.select(); } this.query.set(""); this.open.set(true); } /** * @description The selection: items carrying `aria-selected="true"`, in DOM * order. The single source of truth for tags and form values alike. * * @returns The selected items. * @private */ #selectedItems() { return this.items().filter((item) => item.getAttribute("aria-selected") === "true"); } /** * @description The single-select committed label. * * @returns The selected item's label, or undefined when nothing is selected. * @private */ #selectedLabel() { const selected = this.#selectedItems()[0]; return selected ? this.itemValue(selected) : undefined; } /** * @description An item's machine value. * * @param item - The item element. * @returns `data-value` when present, the visible label otherwise. * @private */ #itemFormValue(item) { return item.getAttribute("data-value") ?? this.itemValue(item); } /** * @description Drops one item from the selection. * * @param item - The item to deselect; a no-op when undefined. * @private */ #deselect(item) { if (!item) return; item.setAttribute("aria-selected", "false"); this.#syncSelection(true); } /** * @description The authored hidden input carrying the form value (never one * of the stamped siblings). * * @returns The hidden input, or null when the author omitted it. * @private */ #valueInput() { const hidden = this.querySelector('[data-slot~="combobox-value"]:not([data-combobox-stamped])'); return hidden instanceof HTMLInputElement ? hidden : null; } /** * @description Mirrors the selection into the two things derived from it: the * tags and the hidden inputs. The authored input carries the first value and * stamped siblings carry the rest under the same `name`, so a multiselection * submits as repeated pairs exactly like `<select multiple>`. DOM * construction stays imperative: the DOM is the source of truth here, not a * signal. * * @param notify - Whether to dispatch `change` (skipped while seeding). * @private */ #syncSelection(notify) { const input = this.searchInput; const values = this.#selectedItems().map((item) => this.#itemFormValue(item)); const serialized = JSON.stringify(values); const changed = serialized !== this.#serialized; this.#serialized = serialized; if (input && this.#multiselect()) this.#renderTags(input); const hidden = this.#valueInput(); if (!hidden) return; hidden.value = values[0] ?? ""; for (const stale of this.querySelectorAll("[data-combobox-stamped]")) stale.remove(); let anchor = hidden; for (const value of values.slice(1)) { const extra = document.createElement("input"); extra.type = "hidden"; extra.setAttribute("data-slot", "combobox-value"); extra.setAttribute("data-combobox-stamped", ""); if (hidden.name) extra.name = hidden.name; extra.value = value; anchor.after(extra); anchor = extra; } if (notify && changed) hidden.dispatchEvent(new Event("change", { bubbles: true })); } /** * @description The element cloned per selected value: the first child of an * authored `<template data-slot="combobox-tag-template">` when present, the * default ui-badge otherwise. A template's content lives in a separate * fragment, so a blueprint carrying `data-slot="combobox-tag"` is invisible to * the stale-tag sweep and can never be mistaken for a rendered tag. * * @returns The blueprint element, or null when an authored template is empty. * @private */ #tagBlueprint() { const authored = this.querySelector('[data-slot~="combobox-tag-template"]'); const template = authored instanceof HTMLTemplateElement ? authored : defaultTagBlueprint(); const root = template.content.firstElementChild; return root instanceof HTMLElement ? root : null; } /** * @description Re-stamps the selection tags inside the control, in DOM order. * Rebuilt wholesale rather than diffed: the tags are pure output, nothing * focusable ever lands inside one, and removal is delegated from the control. * * @param input - The search input the tags render before. * @private */ #renderTags(input) { const control = input.closest('[data-slot~="combobox-control"]'); if (!control) return; for (const stale of control.querySelectorAll('[data-slot~="combobox-tag"]')) stale.remove(); const blueprint = this.#tagBlueprint(); if (!blueprint) return; const removeLabel = this.getAttribute("data-label-remove") ?? "Remove {label}"; const tags = this.#selectedItems().map((item) => { const label = this.itemValue(item); const tag = blueprint.cloneNode(true); tag.setAttribute("data-value", this.#itemFormValue(item)); // The slot is the contract every other moving part keys off (the CSS, the // stale sweep above, and tag removal), so add the token if the template // left it out rather than stamping an orphan if (!tag.matches('[data-slot~="combobox-tag"]')) { const slots = tag.getAttribute("data-slot"); tag.setAttribute("data-slot", slots ? `${slots} combobox-tag` : "combobox-tag"); } // The label wants its own box: text-overflow ignores a flex container's // own text, but a template without one still gets its text const text = tag.querySelector('[data-slot~="combobox-tag-label"]'); if (text) text.textContent = label; else tag.prepend(document.createTextNode(label)); tag .querySelector('[data-slot~="combobox-tag-remove"]') ?.setAttribute("aria-label", resolveRemoveLabel(removeLabel, label)); return tag; }); input.before(...tags); input.placeholder = tags.length > 0 ? "" : this.#placeholder; } /** * @description Empties the committed state: label, hidden value, tags, and * `aria-selected` all clear together. * @private */ #clearSelection() { this.#committedLabel = ""; const multiselect = this.#multiselect(); for (const item of this.items()) { if (multiselect) item.setAttribute("aria-selected", "false"); else item.removeAttribute("aria-selected"); } this.#syncSelection(true); } /** * @description Restores the selection the markup shipped with (form reset). * @private */ #restoreDefaults() { const input = this.searchInput; if (!input) return; const multiselect = this.#multiselect(); for (const item of this.items()) { const selected = this.#defaultValues.includes(this.#itemFormValue(item)); if (multiselect) item.setAttribute("aria-selected", String(selected)); else if (selected) item.setAttribute("aria-selected", "true"); else item.removeAttribute("aria-selected"); } this.#committedLabel = multiselect ? "" : (this.#selectedLabel() ?? ""); input.value = this.#committedLabel; this.query.set(""); this.#syncSelection(true); }}defineZazzElement("ui-combobox", UiCombobox);export { UiCombobox, resolveBlur, resolveRemoveLabel };/** * combobox.css: Combobox (ui-combobox | .ui-combobox, [data-slot~="combobox-control"]) * * @layer variables, components * @requires layers.css, _variables.css, popover.css, button.css, fields.css, * badge.css (--ui-badge-* for the default tag template .ui-badge), * select.css (--ui-option-*, --ui-select-picker-icon-*) * @uses [popover="manual"]: panel opened/closed by combobox.js * @uses anchor-name, anchor-scope, position-anchor: tether panel to the control * @uses anchor-size(width): match panel width to the control (@supports gated) * @uses :has(): chevron rotation, group + empty-state visibility * @uses mask: chevron and tag-remove icons; wiped in forced-colors mode, * matching the rest of the kit (option checkmark, picker icon) * @uses data-side / data-align: maps to --ui-popover-position-* in popover.css * @uses data-variant="multiselect": tag variant; combobox.js clones a * <template data-slot="combobox-tag-template"> (or its muted-badge * default) per option carrying aria-selected="true". Tag rules key * off the slots, so a custom template keeps this styling. * @tokens --ui-combobox-* (@layer variables); control mirrors --ui-select-*, * items alias --ui-option-*, tags alias --ui-badge-* * @see combobox.ts: filtering, commit/revert, tags, and keyboard behavior */@layer variables { :root { /* Control mirrors the select trigger so the two pickers read as one family */ --ui-combobox-display: grid; --ui-combobox-option-gap: var(--ui-field-option-gap); --ui-combobox-inline-size: var(--ui-select-inline-size); --ui-combobox-min-inline-size: var(--ui-select-min-inline-size); --ui-combobox-padding-inline-start: var(--ui-select-padding-inline-start); --ui-combobox-padding-inline-end: var(--ui-select-padding-inline-end); --ui-combobox-border: var(--ui-select-border); --ui-combobox-border-radius: var(--ui-select-border-radius); --ui-combobox-icon-mask: var(--ui-select-picker-icon-mask); --ui-combobox-icon-size: var(--ui-select-picker-icon-size); --ui-combobox-icon-color: var(--ui-select-picker-icon-color); --ui-combobox-panel-max-block-size: var(--step-72); --ui-combobox-shadow: var(--ui-menu-shadow); /* Control box: the multiselect variant swaps these to wrap into rows */ --ui-combobox-control-align: center; --ui-combobox-control-wrap: nowrap; --ui-combobox-control-gap: var(--ui-field-gap); --ui-combobox-control-block-size: var(--ui-field-height); --ui-combobox-control-min-block-size: auto; --ui-combobox-control-padding-block: 0; --ui-combobox-input-min-inline-size: 0; --ui-combobox-input-min-block-size: auto; /* Selection tags (data-variant="multiselect") ** Remove icon re-declared here: the identical --search-cancel-mask lives ** inside ::-webkit-search-cancel-button in _reset.css, out of reach. ** @see https://www.svgbackgrounds.com/tools/svg-to-css/ ** Set to Legacy / URL Wrapper to update icon */ --ui-combobox-tag-gap: var(--step-1); --ui-combobox-tag-padding-block: var(--step-1); --ui-combobox-tag-max-inline-size: 100%; --ui-combobox-tag-remove-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Crect width='256' height='256' fill='none'/%3E%3Cline x1='200' y1='56' x2='56' y2='200' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3Cline x1='200' y1='200' x2='56' y2='56' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E"); --ui-combobox-tag-remove-size: var(--ui-badge-icon-size); --ui-combobox-tag-remove-color: currentColor; --ui-combobox-tag-remove-radius: var(--radius-sm); --ui-combobox-tag-remove-opacity: 0.65; --ui-combobox-tag-remove-opacity--hover: 1; }}@layer zazz.components { /* =========================================================================== COMBOBOX: select-look control + anchored filterable panel - The control shell carries the field surface (border, ring, chevron); the inner input is bare so nothing double-borders. The panel rides popover.css's surface + placement like autocomplete's. =========================================================================== */ :where(ui-combobox, .ui-combobox) { anchor-scope: --ui-combobox-trigger; display: flex; flex-direction: column; gap: var(--step-px); inline-size: var(--ui-combobox-inline-size); } [data-slot~="combobox-control"] { anchor-name: --ui-combobox-trigger; display: flex; flex-wrap: var(--ui-combobox-control-wrap); align-items: var(--ui-combobox-control-align); gap: var(--ui-combobox-control-gap); inline-size: 100%; min-inline-size: var(--ui-combobox-min-inline-size); block-size: var(--ui-combobox-control-block-size); min-block-size: var(--ui-combobox-control-min-block-size); padding-block: var(--ui-combobox-control-padding-block); padding-inline: var(--ui-combobox-padding-inline-start) var(--ui-combobox-padding-inline-end); font-size: var(--ui-field-font-size); line-height: var(--ui-field-line-height); color: var(--ui-field-foreground); background-color: var(--ui-field-background); border: var(--ui-combobox-border); border-radius: var(--ui-combobox-border-radius); /* Same ring recipe as .ui-select: transparent outline twin keeps focus visible in forced-colors mode */ --_ring-offset-width: 0px; --_ring-width: 0px; --_ring: color-mix(in oklch, var(--ui-field-ring-color) var(--ring-opacity), transparent); box-shadow: 0 0 0 var(--_ring-offset-width) var(--ring-offset-color), 0 0 0 calc(var(--_ring-offset-width) + var(--_ring-width)) var(--_ring, var(--ring)); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } [data-slot~="combobox-control"]:hover { border-color: var(--ui-field-border--hover); } [data-slot~="combobox-control"]:focus-within { border-color: var(--ui-field-border--focus); background-color: var(--ui-field-background--focus); --_ring-offset-width: var(--ring-offset-width); --_ring-width: var(--ring-width); outline-color: transparent; } [data-slot~="combobox-control"]:has(> [data-slot~="combobox-tag"]) { padding-inline-start: var(--step-1_5); } /* The inner input is bare: the shell owns the surface. It keeps a minimum width so the multiselect variant wraps it onto its own row instead of collapsing to nothing between the tags and the chevron. */ [data-slot~="combobox-control"] input { flex: 1 1 var(--ui-combobox-input-min-inline-size); min-inline-size: var(--ui-combobox-input-min-inline-size); min-block-size: var(--ui-combobox-input-min-block-size); font: inherit; color: inherit; background: none; border: none; outline: none; } [data-slot~="combobox-trigger"] { display: grid; place-items: center; inline-size: var(--ui-combobox-icon-size); block-size: var(--ui-combobox-icon-size); flex-shrink: 0; cursor: pointer; margin-inline-start: auto; } [data-slot~="combobox-trigger"]::before { content: ""; inline-size: var(--ui-combobox-icon-size); block-size: var(--ui-combobox-icon-size); background-color: var(--ui-combobox-icon-color); mask: var(--ui-combobox-icon-mask) center / contain no-repeat; transition: var(--default-transition); } /* Panel: same shape as the autocomplete panel */ [data-slot~="combobox-panel"] { position-anchor: --ui-combobox-trigger; /* flex-direction stays ungated so the close fade keeps the column layout */ flex-direction: column; max-block-size: var(--ui-combobox-panel-max-block-size); overflow: auto; box-shadow: var(--ui-combobox-shadow); } /* Only display is gated; see popover.css: closed popovers stay display: none */ [data-slot~="combobox-panel"]:where(:popover-open, .\:popover-open) { display: flex; } /* Inline variant: no [popover]: the list renders in flow, always open, without the floating surface chrome (combobox.js treats it as such) */ [data-slot~="combobox-panel"]:not([popover]) { display: flex; box-shadow: none; } @supports (inline-size: anchor-size(width)) { [data-slot~="combobox-panel"] { min-inline-size: anchor-size(width); } } [data-slot~="combobox-list"] { display: flex; flex-direction: column; gap: var(--ui-combobox-option-gap); margin: 0; padding: 0; list-style: none; } [data-slot~="combobox-group"] { display: flex; flex-direction: column; gap: var(--ui-combobox-option-gap); } /* Score ranking writes order -1000..0 on sibling items: keep the label first */ [data-slot~="combobox-group-label"] { order: -1001; } [data-slot~="combobox-item"] { cursor: pointer; } [data-slot~="combobox-item"][data-highlighted] { --ui-button-background: var(--ui-button-background--hover); --ui-button-foreground: var(--ui-button-foreground--hover); } /* The committed item wears the shared option checkmark */ [data-slot~="combobox-item"][aria-selected="true"]::after { content: ""; order: 1; margin-inline-start: auto; inline-size: var(--ui-option-checkmark-size); block-size: var(--ui-option-checkmark-size); background-color: var(--ui-option-checkmark-color); mask: var(--ui-option-checkmark-mask) center / contain no-repeat; } [data-slot~="combobox-group"]:not(:has([data-slot~="combobox-item"]:not([hidden]))) { display: none; } [data-slot~="combobox-empty"] { display: none; padding: var(--step-2); } [data-slot~="combobox-panel"]:not(:has([data-slot~="combobox-item"]:not([hidden]))) [data-slot~="combobox-empty"] { display: block; } /* =========================================================================== COMBOBOX MULTISELECT: selected values as removable tags inside the control - The variant only swaps tokens: the control wraps into rows and grows from a fixed height into a minimum one. combobox.js stamps the tags from the items carrying aria-selected="true", dressed as .ui-badge[data-variant="muted"] so a selection tag and a badge read as one thing. =========================================================================== */ :where(ui-combobox, .ui-combobox)[data-variant="multiselect"] { --ui-combobox-control-wrap: wrap; --ui-combobox-control-gap: var(--ui-combobox-tag-gap); --ui-combobox-control-block-size: auto; --ui-combobox-control-min-block-size: var(--ui-field-height); --ui-combobox-control-padding-block: var(--ui-combobox-tag-padding-block); --ui-combobox-input-min-inline-size: var(--step-16); --ui-combobox-input-min-block-size: var(--ui-badge-height); } /* Overrides badge.css min-width: max-content (same layer, same specificity, and combobox.css imports after badge.css) */ [data-slot~="combobox-tag"] { min-inline-size: 0; max-inline-size: var(--ui-combobox-tag-max-inline-size); } /* The label wears its own box because text-overflow ignores a flex container's own text, and .ui-badge is display: flex */ [data-slot~="combobox-tag-label"] { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } [data-slot~="combobox-tag-remove"] { display: grid; place-items: center; flex-shrink: 0; inline-size: var(--ui-combobox-tag-remove-size); block-size: var(--ui-combobox-tag-remove-size); border-radius: var(--ui-combobox-tag-remove-radius); opacity: var(--ui-combobox-tag-remove-opacity); cursor: pointer; transition: var(--default-transition); } [data-slot~="combobox-tag-remove"]::before { content: ""; inline-size: 100%; block-size: 100%; background-color: var(--ui-combobox-tag-remove-color); mask: var(--ui-combobox-tag-remove-mask) center / contain no-repeat; } [data-slot~="combobox-tag-remove"]:hover { opacity: var(--ui-combobox-tag-remove-opacity--hover); }}Multiselect
Add data-variant="multiselect" to select a set of values. Committing toggles a row instead of replacing the selection, and the panel stays open to allow multiple picks. Each selected value renders as a removable tag inside the control while the input continues filtering beside them. Pressing Backspace on an empty input removes the last tag, and the control wraps onto extra rows as tags accumulate.
A tag's markup is HTML, not script. Author a <template data-slot="combobox-tag-template"> and the combobox clones it once per selected value, filling in the label (combobox-tag-label), the value, and the remove button's accessible name (combobox-tag-remove). Changing the badge variant, adding an icon, or swapping the component requires no script changes. Omitting the template uses the default muted badge.
Options with aria-selected="true" are the single source of truth. Tags and hidden inputs derive from them in DOM order. The form submits repeated name pairs like <select multiple>: the authored hidden input carries the first value, and stamped siblings carry the rest. Because tags are stamped by script, server-rendered selections stay invisible without JavaScript (though values still submit). Where server-rendered initial state matters, use <ui-multiselect> instead.
<div class="ui-field max-w-xl"> <label data-slot="field-label" for="cb-tags">Tags</label> <ui-combobox data-variant="multiselect" data-label-remove="Remove {label}"> <!-- Pre-selected rows carry aria-selected="true"; the authored input holds the first value and combobox.js stamps a same-name sibling per extra --> <input type="hidden" name="tags" data-slot="combobox-value" value="design" /> <div data-slot="combobox-control"> <!-- Optional: the tag blueprint, cloned per selection. Omit it and the kit's default applies; keep it to restyle tags in HTML --> <template data-slot="combobox-tag-template"> <span class="ui-badge" data-slot="combobox-tag"> <span data-slot="combobox-tag-label"></span> <button type="button" tabindex="-1" data-slot="combobox-tag-remove"></button> </span> </template> <input id="cb-tags" type="text" placeholder="Add tags..." role="combobox" aria-expanded="false" aria-autocomplete="list" autocomplete="off" hidden aria-hidden="true" /> <button type="button" data-slot="combobox-trigger" tabindex="-1" aria-label="Show options" ></button> </div> <div data-slot="combobox-panel" data-side="bottom" data-align="start" popover="manual"> <ul role="listbox" aria-multiselectable="true" data-slot="combobox-list" aria-label="Tags"> <li role="option" aria-selected="true" data-slot="combobox-item" class="ui-button justify-start" data-variant="ghost" data-value="design" > Design </li> <li role="option" aria-selected="true" data-slot="combobox-item" class="ui-button justify-start" data-variant="ghost" data-value="engineering" > Engineering </li> <li role="option" aria-selected="false" data-slot="combobox-item" class="ui-button justify-start" data-variant="ghost" data-value="marketing" > Marketing </li> <li role="option" aria-selected="false" data-slot="combobox-item" class="ui-button justify-start" data-variant="ghost" data-value="research" > Research </li> <li role="option" aria-selected="false" data-slot="combobox-item" class="ui-button justify-start" data-variant="ghost" data-value="support" > Support </li> </ul> <p data-slot="combobox-empty" class="text-muted-foreground text-sm">No matches.</p> </div> </ui-combobox></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/combobox/combobox.js"use strict";/** * @fileoverview `<ui-combobox>`: an input restricted to a predefined list. * @description Light-DOM custom element on the shared typeahead engine * (`base/typeahead.ts`). A relative of select: the panel filters as you type, * but free text can never submit. The form value lives in an authored hidden * input (`data-slot="combobox-value"`), synced whenever the selection changes. * * The visible input carries a **display value, not the filter**. Committing * writes the option label into it and clears the query, so reopening shows * the full list with the committed row checked, matching `.ui-select`. * Opening selects that label so the first keystroke replaces it, and focus * alone never opens the panel (a click, the chevron, an arrow key or typing * does). On blur, stray text reverts to the committed label and cleared text * clears the selection. * * `data-variant="multiselect"` selects a set instead: committing toggles a row * and keeps the panel open, and every selected value renders as a removable * tag stamped inside the control. The tag markup is HTML, not script: author * a `<template data-slot="combobox-tag-template">` to control its classes and * structure, and this file fills in the label, the value, and the remove * button accessible name. Without one, the ui-badge default below applies. * Backspace on an empty input drops the last tag. Options carrying * `aria-selected="true"` are the single source of truth: the tags and the * hidden inputs are both derived from them in DOM order so nothing can drift. * The form submits repeated `name` pairs like `<select multiple>`: the authored * hidden input carries the first value, and stamped siblings carry the rest. * * Where a no-JS fallback matters, prefer `.ui-select` / `<ui-multiselect>`: * this control is inert without its script (the hidden input still submits a * server-set value, but no tags render). * * Attributes on the root: `data-variant="multiselect"`, `data-sort="score"`, * `data-min-length="<n>"`, `data-label-remove="Remove {label}"`. * Parts: `combobox-value` (hidden input), `combobox-control` (select-look * shell), `combobox-tag-template` (authored `<template>`), `combobox-tag` / * `combobox-tag-label` / `combobox-tag-remove` (cloned from it), * `combobox-trigger` (chevron), `combobox-panel`, `combobox-list`, * `combobox-item`, `combobox-group` / `combobox-group-label`, `combobox-empty`. */import { TypeaheadElement } from "../../base/typeahead.js";import { defineZazzElement } from "../../base/zazz-element.js";// --- Tag blueprint ---/** * The default tag markup, cloned once per selected value. Authors override it * wholesale with a `<template data-slot="combobox-tag-template">`, so restyling * a tag (other classes, an extra icon, a different component entirely) never * means editing this script. Kept as markup rather than createElement calls so * the default reads the same way the override is written. */const TAG_MARKUP = '<span class="ui-badge" data-slot="combobox-tag">' + '<span data-slot="combobox-tag-label"></span>' + '<button type="button" tabindex="-1" data-slot="combobox-tag-remove"></button>' + "</span>";let defaultTagTemplate = null;/** * @description Parses `TAG_MARKUP` once and shares the result. * * @returns The fallback tag template. * @private */function defaultTagBlueprint() { if (!defaultTagTemplate) { defaultTagTemplate = document.createElement("template"); defaultTagTemplate.innerHTML = TAG_MARKUP; } return defaultTagTemplate;}/** * @description Resolves a blur: free text never survives it. The multiselect * variant always empties the filter (its selection lives in the tags), the * single variant restores the committed label, and an emptied single-select * input clears the selection outright. * * @param typed - The input's current text. * @param committedLabel - The last committed label, empty for no selection. * @param multiselect - Whether the multiselect variant is active. * @returns The text to show and whether to clear the selection. */function resolveBlur(typed, committedLabel, multiselect) { if (multiselect) return { value: "", clear: false }; if (typed === "") return { value: "", clear: true }; return { value: committedLabel, clear: false };}/** * @description Fills the remove-button label template (`data-label-remove`). * * @param template - Template with a `{label}` placeholder. * @param label - The tag's visible label. * @returns The accessible name for that tag's remove button. */function resolveRemoveLabel(template, label) { return template.replace("{label}", label);}// --- Element ---class UiCombobox extends TypeaheadElement { slotPrefix = "combobox"; /** The input holds a committed label, so focus alone must not open the list. */ openOnFocus = false; #committedLabel = ""; #placeholder = ""; #defaultValues = []; #serialized = ""; setup(signal) { super.setup(signal); const input = this.searchInput; if (!input) return; const multiselect = this.#multiselect(); this.#placeholder = input.getAttribute("placeholder") ?? ""; if (multiselect) { this.querySelector('[data-slot~="combobox-list"]')?.setAttribute("aria-multiselectable", "true"); } // Adopt a server-rendered value no row claims yet, so `value` alone is // enough markup to seed the selection, ensuring the sync below never // silently discards it const hidden = this.#valueInput(); if (hidden !== null && hidden.value !== "" && this.#selectedItems().length === 0) { const match = this.items().find((item) => this.#itemFormValue(item) === hidden.value); match?.setAttribute("aria-selected", "true"); } // A multi-select listbox announces every row state, not just the picked ones if (multiselect) { for (const item of this.items()) { if (item.getAttribute("aria-selected") !== "true") { item.setAttribute("aria-selected", "false"); } } } // Attribute state has no defaultValue: remember the markup selection so // a form reset can restore it this.#defaultValues = this.#selectedItems().map((item) => this.#itemFormValue(item)); this.#committedLabel = multiselect ? "" : (this.#selectedLabel() ?? ""); input.value = this.#committedLabel; this.#syncSelection(false); const control = input.closest('[data-slot~="combobox-control"]'); // A select-look control is one big hit target input.addEventListener("click", () => this.#openPanel(), { signal }); control?.addEventListener("mousedown", (event) => { if (!(event.target instanceof Element) || event.target === input) return; // Nothing in the shell may steal focus from the input: the chevron and // the tag remove buttons would otherwise blur it and close the panel event.preventDefault(); input.focus(); if (!event.target.closest('[data-slot~="combobox-trigger"], [data-slot~="combobox-tag"]')) { this.#openPanel(); } }, { signal }); // Tag removal is delegated: the tags are re-stamped on every change control?.addEventListener("click", (event) => { if (!(event.target instanceof Element)) return; const remove = event.target.closest('[data-slot~="combobox-tag-remove"]'); if (!remove) return; // A template that forgets type="button" would otherwise submit the form event.preventDefault(); const value = remove.closest('[data-slot~="combobox-tag"]')?.getAttribute("data-value"); this.#deselect(this.items().find((item) => this.#itemFormValue(item) === value)); input.focus(); }, { signal }); // Chevron toggles the full, unfiltered list const trigger = this.querySelector('[data-slot~="combobox-trigger"]'); if (trigger instanceof HTMLElement) { trigger.addEventListener("click", () => { if (this.open.get()) this.open.set(false); else this.#openPanel(); }, { signal }); } // Backspace on an empty filter drops the last tag if (multiselect) { input.addEventListener("keydown", (event) => { if (event.key !== "Backspace" || input.value !== "") return; const last = this.#selectedItems().at(-1); if (!last) return; event.preventDefault(); this.#deselect(last); }, { signal }); } // No free text: on leaving, revert stray text or clear the selection this.addEventListener("focusout", (event) => { const next = event.relatedTarget; if (next instanceof Node && this.contains(next)) return; const outcome = resolveBlur(input.value, this.#committedLabel, this.#multiselect()); input.value = outcome.value; // Safe mid-close: the gated ranking effect ignores query writes while // the panel is fading out this.query.set(""); if (outcome.clear) this.#clearSelection(); }, { signal }); // A form reset restores the selection the markup shipped with (reset // applies after the event) (hidden ?? input).form?.addEventListener("reset", () => queueMicrotask(() => this.#restoreDefaults()), { signal }); } teardown() { for (const node of this.querySelectorAll('[data-slot~="combobox-tag"], [data-combobox-stamped]')) { node.remove(); } if (this.searchInput) this.searchInput.placeholder = this.#placeholder; } /** * @description Combobox items match against what the user sees (the label) * not the machine `data-value`. * * @param item - The item element. * @returns The trimmed visible label. */ itemValue(item) { return item.textContent?.trim() ?? ""; } /** * @description Escape with the panel already closed restores the committed * label (single) or drops the filter text (multiselect), avoiding a half-typed * value that a later blur would read as a cleared selection. */ clearQuery() { const input = this.searchInput; if (!input) return; input.value = this.#multiselect() ? "" : this.#committedLabel; this.query.set(""); } /** * @description Committing shows the item's label, stores its `data-value` in * the hidden input, and moves `aria-selected`. The multiselect variant * toggles the row instead and keeps the panel open so picking can continue. * * @param item - The committed option. */ commit(item, _source) { const input = this.searchInput; if (!input) return; if (this.#multiselect()) { item.setAttribute("aria-selected", String(item.getAttribute("aria-selected") !== "true")); input.value = ""; this.query.set(""); // Follow the row through the re-widened list so a second Enter toggles it // back: with an empty query every item is visible, and visual order is // DOM order (score ranking is stable at equal scores) this.activeIndex.set(this.items().indexOf(item)); this.#syncSelection(true); input.focus(); return; } this.#committedLabel = this.itemValue(item); input.value = this.#committedLabel; // The label is display text, not a filter: clearing the query is what // makes the reopened panel show the full list with this row checked this.query.set(""); for (const other of this.items()) other.removeAttribute("aria-selected"); item.setAttribute("aria-selected", "true"); this.#syncSelection(true); input.focus(); // The inline variant has no popover to close, and closing it would gate its // filtering off for good. It also never reopens, so commit is where it // selects the label (the popover variants do that in #openPanel()). if (this.panel?.hasAttribute("popover")) this.open.set(false); else input.select(); } /** * @description Whether the multiselect variant is active. * * @returns True for `data-variant="multiselect"`. * @private */ #multiselect() { return this.getAttribute("data-variant") === "multiselect"; } /** * @description Opens the panel the way a select does: the full list, the * committed row highlighted, and its label selected so the first keystroke * replaces it. `activeIndex` is written here rather than from an effect * because effects are output adapters, and writes from inside one can be dropped * until the next notification. * @private */ #openPanel() { const input = this.searchInput; if (!input || this.open.get()) return; if (this.#multiselect()) { input.value = ""; this.activeIndex.set(-1); } else { input.value = this.#committedLabel; const selected = this.#selectedItems()[0]; this.activeIndex.set(selected ? this.items().indexOf(selected) : -1); if (input.value !== "") input.select(); } this.query.set(""); this.open.set(true); } /** * @description The selection: items carrying `aria-selected="true"`, in DOM * order. The single source of truth for tags and form values alike. * * @returns The selected items. * @private */ #selectedItems() { return this.items().filter((item) => item.getAttribute("aria-selected") === "true"); } /** * @description The single-select committed label. * * @returns The selected item's label, or undefined when nothing is selected. * @private */ #selectedLabel() { const selected = this.#selectedItems()[0]; return selected ? this.itemValue(selected) : undefined; } /** * @description An item's machine value. * * @param item - The item element. * @returns `data-value` when present, the visible label otherwise. * @private */ #itemFormValue(item) { return item.getAttribute("data-value") ?? this.itemValue(item); } /** * @description Drops one item from the selection. * * @param item - The item to deselect; a no-op when undefined. * @private */ #deselect(item) { if (!item) return; item.setAttribute("aria-selected", "false"); this.#syncSelection(true); } /** * @description The authored hidden input carrying the form value (never one * of the stamped siblings). * * @returns The hidden input, or null when the author omitted it. * @private */ #valueInput() { const hidden = this.querySelector('[data-slot~="combobox-value"]:not([data-combobox-stamped])'); return hidden instanceof HTMLInputElement ? hidden : null; } /** * @description Mirrors the selection into the two things derived from it: the * tags and the hidden inputs. The authored input carries the first value and * stamped siblings carry the rest under the same `name`, so a multiselection * submits as repeated pairs exactly like `<select multiple>`. DOM * construction stays imperative: the DOM is the source of truth here, not a * signal. * * @param notify - Whether to dispatch `change` (skipped while seeding). * @private */ #syncSelection(notify) { const input = this.searchInput; const values = this.#selectedItems().map((item) => this.#itemFormValue(item)); const serialized = JSON.stringify(values); const changed = serialized !== this.#serialized; this.#serialized = serialized; if (input && this.#multiselect()) this.#renderTags(input); const hidden = this.#valueInput(); if (!hidden) return; hidden.value = values[0] ?? ""; for (const stale of this.querySelectorAll("[data-combobox-stamped]")) stale.remove(); let anchor = hidden; for (const value of values.slice(1)) { const extra = document.createElement("input"); extra.type = "hidden"; extra.setAttribute("data-slot", "combobox-value"); extra.setAttribute("data-combobox-stamped", ""); if (hidden.name) extra.name = hidden.name; extra.value = value; anchor.after(extra); anchor = extra; } if (notify && changed) hidden.dispatchEvent(new Event("change", { bubbles: true })); } /** * @description The element cloned per selected value: the first child of an * authored `<template data-slot="combobox-tag-template">` when present, the * default ui-badge otherwise. A template's content lives in a separate * fragment, so a blueprint carrying `data-slot="combobox-tag"` is invisible to * the stale-tag sweep and can never be mistaken for a rendered tag. * * @returns The blueprint element, or null when an authored template is empty. * @private */ #tagBlueprint() { const authored = this.querySelector('[data-slot~="combobox-tag-template"]'); const template = authored instanceof HTMLTemplateElement ? authored : defaultTagBlueprint(); const root = template.content.firstElementChild; return root instanceof HTMLElement ? root : null; } /** * @description Re-stamps the selection tags inside the control, in DOM order. * Rebuilt wholesale rather than diffed: the tags are pure output, nothing * focusable ever lands inside one, and removal is delegated from the control. * * @param input - The search input the tags render before. * @private */ #renderTags(input) { const control = input.closest('[data-slot~="combobox-control"]'); if (!control) return; for (const stale of control.querySelectorAll('[data-slot~="combobox-tag"]')) stale.remove(); const blueprint = this.#tagBlueprint(); if (!blueprint) return; const removeLabel = this.getAttribute("data-label-remove") ?? "Remove {label}"; const tags = this.#selectedItems().map((item) => { const label = this.itemValue(item); const tag = blueprint.cloneNode(true); tag.setAttribute("data-value", this.#itemFormValue(item)); // The slot is the contract every other moving part keys off (the CSS, the // stale sweep above, and tag removal), so add the token if the template // left it out rather than stamping an orphan if (!tag.matches('[data-slot~="combobox-tag"]')) { const slots = tag.getAttribute("data-slot"); tag.setAttribute("data-slot", slots ? `${slots} combobox-tag` : "combobox-tag"); } // The label wants its own box: text-overflow ignores a flex container's // own text, but a template without one still gets its text const text = tag.querySelector('[data-slot~="combobox-tag-label"]'); if (text) text.textContent = label; else tag.prepend(document.createTextNode(label)); tag .querySelector('[data-slot~="combobox-tag-remove"]') ?.setAttribute("aria-label", resolveRemoveLabel(removeLabel, label)); return tag; }); input.before(...tags); input.placeholder = tags.length > 0 ? "" : this.#placeholder; } /** * @description Empties the committed state: label, hidden value, tags, and * `aria-selected` all clear together. * @private */ #clearSelection() { this.#committedLabel = ""; const multiselect = this.#multiselect(); for (const item of this.items()) { if (multiselect) item.setAttribute("aria-selected", "false"); else item.removeAttribute("aria-selected"); } this.#syncSelection(true); } /** * @description Restores the selection the markup shipped with (form reset). * @private */ #restoreDefaults() { const input = this.searchInput; if (!input) return; const multiselect = this.#multiselect(); for (const item of this.items()) { const selected = this.#defaultValues.includes(this.#itemFormValue(item)); if (multiselect) item.setAttribute("aria-selected", String(selected)); else if (selected) item.setAttribute("aria-selected", "true"); else item.removeAttribute("aria-selected"); } this.#committedLabel = multiselect ? "" : (this.#selectedLabel() ?? ""); input.value = this.#committedLabel; this.query.set(""); this.#syncSelection(true); }}defineZazzElement("ui-combobox", UiCombobox);export { UiCombobox, resolveBlur, resolveRemoveLabel };/** * combobox.css: Combobox (ui-combobox | .ui-combobox, [data-slot~="combobox-control"]) * * @layer variables, components * @requires layers.css, _variables.css, popover.css, button.css, fields.css, * badge.css (--ui-badge-* for the default tag template .ui-badge), * select.css (--ui-option-*, --ui-select-picker-icon-*) * @uses [popover="manual"]: panel opened/closed by combobox.js * @uses anchor-name, anchor-scope, position-anchor: tether panel to the control * @uses anchor-size(width): match panel width to the control (@supports gated) * @uses :has(): chevron rotation, group + empty-state visibility * @uses mask: chevron and tag-remove icons; wiped in forced-colors mode, * matching the rest of the kit (option checkmark, picker icon) * @uses data-side / data-align: maps to --ui-popover-position-* in popover.css * @uses data-variant="multiselect": tag variant; combobox.js clones a * <template data-slot="combobox-tag-template"> (or its muted-badge * default) per option carrying aria-selected="true". Tag rules key * off the slots, so a custom template keeps this styling. * @tokens --ui-combobox-* (@layer variables); control mirrors --ui-select-*, * items alias --ui-option-*, tags alias --ui-badge-* * @see combobox.ts: filtering, commit/revert, tags, and keyboard behavior */@layer variables { :root { /* Control mirrors the select trigger so the two pickers read as one family */ --ui-combobox-display: grid; --ui-combobox-option-gap: var(--ui-field-option-gap); --ui-combobox-inline-size: var(--ui-select-inline-size); --ui-combobox-min-inline-size: var(--ui-select-min-inline-size); --ui-combobox-padding-inline-start: var(--ui-select-padding-inline-start); --ui-combobox-padding-inline-end: var(--ui-select-padding-inline-end); --ui-combobox-border: var(--ui-select-border); --ui-combobox-border-radius: var(--ui-select-border-radius); --ui-combobox-icon-mask: var(--ui-select-picker-icon-mask); --ui-combobox-icon-size: var(--ui-select-picker-icon-size); --ui-combobox-icon-color: var(--ui-select-picker-icon-color); --ui-combobox-panel-max-block-size: var(--step-72); --ui-combobox-shadow: var(--ui-menu-shadow); /* Control box: the multiselect variant swaps these to wrap into rows */ --ui-combobox-control-align: center; --ui-combobox-control-wrap: nowrap; --ui-combobox-control-gap: var(--ui-field-gap); --ui-combobox-control-block-size: var(--ui-field-height); --ui-combobox-control-min-block-size: auto; --ui-combobox-control-padding-block: 0; --ui-combobox-input-min-inline-size: 0; --ui-combobox-input-min-block-size: auto; /* Selection tags (data-variant="multiselect") ** Remove icon re-declared here: the identical --search-cancel-mask lives ** inside ::-webkit-search-cancel-button in _reset.css, out of reach. ** @see https://www.svgbackgrounds.com/tools/svg-to-css/ ** Set to Legacy / URL Wrapper to update icon */ --ui-combobox-tag-gap: var(--step-1); --ui-combobox-tag-padding-block: var(--step-1); --ui-combobox-tag-max-inline-size: 100%; --ui-combobox-tag-remove-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 256 256'%3E%3Crect width='256' height='256' fill='none'/%3E%3Cline x1='200' y1='56' x2='56' y2='200' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3Cline x1='200' y1='200' x2='56' y2='56' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E"); --ui-combobox-tag-remove-size: var(--ui-badge-icon-size); --ui-combobox-tag-remove-color: currentColor; --ui-combobox-tag-remove-radius: var(--radius-sm); --ui-combobox-tag-remove-opacity: 0.65; --ui-combobox-tag-remove-opacity--hover: 1; }}@layer zazz.components { /* =========================================================================== COMBOBOX: select-look control + anchored filterable panel - The control shell carries the field surface (border, ring, chevron); the inner input is bare so nothing double-borders. The panel rides popover.css's surface + placement like autocomplete's. =========================================================================== */ :where(ui-combobox, .ui-combobox) { anchor-scope: --ui-combobox-trigger; display: flex; flex-direction: column; gap: var(--step-px); inline-size: var(--ui-combobox-inline-size); } [data-slot~="combobox-control"] { anchor-name: --ui-combobox-trigger; display: flex; flex-wrap: var(--ui-combobox-control-wrap); align-items: var(--ui-combobox-control-align); gap: var(--ui-combobox-control-gap); inline-size: 100%; min-inline-size: var(--ui-combobox-min-inline-size); block-size: var(--ui-combobox-control-block-size); min-block-size: var(--ui-combobox-control-min-block-size); padding-block: var(--ui-combobox-control-padding-block); padding-inline: var(--ui-combobox-padding-inline-start) var(--ui-combobox-padding-inline-end); font-size: var(--ui-field-font-size); line-height: var(--ui-field-line-height); color: var(--ui-field-foreground); background-color: var(--ui-field-background); border: var(--ui-combobox-border); border-radius: var(--ui-combobox-border-radius); /* Same ring recipe as .ui-select: transparent outline twin keeps focus visible in forced-colors mode */ --_ring-offset-width: 0px; --_ring-width: 0px; --_ring: color-mix(in oklch, var(--ui-field-ring-color) var(--ring-opacity), transparent); box-shadow: 0 0 0 var(--_ring-offset-width) var(--ring-offset-color), 0 0 0 calc(var(--_ring-offset-width) + var(--_ring-width)) var(--_ring, var(--ring)); outline: var(--outline-width) var(--outline-style) transparent; outline-offset: var(--outline-offset); transition: var(--default-transition); } [data-slot~="combobox-control"]:hover { border-color: var(--ui-field-border--hover); } [data-slot~="combobox-control"]:focus-within { border-color: var(--ui-field-border--focus); background-color: var(--ui-field-background--focus); --_ring-offset-width: var(--ring-offset-width); --_ring-width: var(--ring-width); outline-color: transparent; } [data-slot~="combobox-control"]:has(> [data-slot~="combobox-tag"]) { padding-inline-start: var(--step-1_5); } /* The inner input is bare: the shell owns the surface. It keeps a minimum width so the multiselect variant wraps it onto its own row instead of collapsing to nothing between the tags and the chevron. */ [data-slot~="combobox-control"] input { flex: 1 1 var(--ui-combobox-input-min-inline-size); min-inline-size: var(--ui-combobox-input-min-inline-size); min-block-size: var(--ui-combobox-input-min-block-size); font: inherit; color: inherit; background: none; border: none; outline: none; } [data-slot~="combobox-trigger"] { display: grid; place-items: center; inline-size: var(--ui-combobox-icon-size); block-size: var(--ui-combobox-icon-size); flex-shrink: 0; cursor: pointer; margin-inline-start: auto; } [data-slot~="combobox-trigger"]::before { content: ""; inline-size: var(--ui-combobox-icon-size); block-size: var(--ui-combobox-icon-size); background-color: var(--ui-combobox-icon-color); mask: var(--ui-combobox-icon-mask) center / contain no-repeat; transition: var(--default-transition); } /* Panel: same shape as the autocomplete panel */ [data-slot~="combobox-panel"] { position-anchor: --ui-combobox-trigger; /* flex-direction stays ungated so the close fade keeps the column layout */ flex-direction: column; max-block-size: var(--ui-combobox-panel-max-block-size); overflow: auto; box-shadow: var(--ui-combobox-shadow); } /* Only display is gated; see popover.css: closed popovers stay display: none */ [data-slot~="combobox-panel"]:where(:popover-open, .\:popover-open) { display: flex; } /* Inline variant: no [popover]: the list renders in flow, always open, without the floating surface chrome (combobox.js treats it as such) */ [data-slot~="combobox-panel"]:not([popover]) { display: flex; box-shadow: none; } @supports (inline-size: anchor-size(width)) { [data-slot~="combobox-panel"] { min-inline-size: anchor-size(width); } } [data-slot~="combobox-list"] { display: flex; flex-direction: column; gap: var(--ui-combobox-option-gap); margin: 0; padding: 0; list-style: none; } [data-slot~="combobox-group"] { display: flex; flex-direction: column; gap: var(--ui-combobox-option-gap); } /* Score ranking writes order -1000..0 on sibling items: keep the label first */ [data-slot~="combobox-group-label"] { order: -1001; } [data-slot~="combobox-item"] { cursor: pointer; } [data-slot~="combobox-item"][data-highlighted] { --ui-button-background: var(--ui-button-background--hover); --ui-button-foreground: var(--ui-button-foreground--hover); } /* The committed item wears the shared option checkmark */ [data-slot~="combobox-item"][aria-selected="true"]::after { content: ""; order: 1; margin-inline-start: auto; inline-size: var(--ui-option-checkmark-size); block-size: var(--ui-option-checkmark-size); background-color: var(--ui-option-checkmark-color); mask: var(--ui-option-checkmark-mask) center / contain no-repeat; } [data-slot~="combobox-group"]:not(:has([data-slot~="combobox-item"]:not([hidden]))) { display: none; } [data-slot~="combobox-empty"] { display: none; padding: var(--step-2); } [data-slot~="combobox-panel"]:not(:has([data-slot~="combobox-item"]:not([hidden]))) [data-slot~="combobox-empty"] { display: block; } /* =========================================================================== COMBOBOX MULTISELECT: selected values as removable tags inside the control - The variant only swaps tokens: the control wraps into rows and grows from a fixed height into a minimum one. combobox.js stamps the tags from the items carrying aria-selected="true", dressed as .ui-badge[data-variant="muted"] so a selection tag and a badge read as one thing. =========================================================================== */ :where(ui-combobox, .ui-combobox)[data-variant="multiselect"] { --ui-combobox-control-wrap: wrap; --ui-combobox-control-gap: var(--ui-combobox-tag-gap); --ui-combobox-control-block-size: auto; --ui-combobox-control-min-block-size: var(--ui-field-height); --ui-combobox-control-padding-block: var(--ui-combobox-tag-padding-block); --ui-combobox-input-min-inline-size: var(--step-16); --ui-combobox-input-min-block-size: var(--ui-badge-height); } /* Overrides badge.css min-width: max-content (same layer, same specificity, and combobox.css imports after badge.css) */ [data-slot~="combobox-tag"] { min-inline-size: 0; max-inline-size: var(--ui-combobox-tag-max-inline-size); } /* The label wears its own box because text-overflow ignores a flex container's own text, and .ui-badge is display: flex */ [data-slot~="combobox-tag-label"] { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } [data-slot~="combobox-tag-remove"] { display: grid; place-items: center; flex-shrink: 0; inline-size: var(--ui-combobox-tag-remove-size); block-size: var(--ui-combobox-tag-remove-size); border-radius: var(--ui-combobox-tag-remove-radius); opacity: var(--ui-combobox-tag-remove-opacity); cursor: pointer; transition: var(--default-transition); } [data-slot~="combobox-tag-remove"]::before { content: ""; inline-size: 100%; block-size: 100%; background-color: var(--ui-combobox-tag-remove-color); mask: var(--ui-combobox-tag-remove-mask) center / contain no-repeat; } [data-slot~="combobox-tag-remove"]:hover { opacity: var(--ui-combobox-tag-remove-opacity--hover); }}API
| Attribute | Target | Values |
|---|---|---|
data-variant | Root | multiselect: select a set, shown as removable tags |
data-label-remove | Root | Tag remove-button label (default: Remove {label}) |
data-slot | <template> in the root | combobox-tag-template: tag blueprint (optional) |
data-value | [data-slot="combobox-item"] | Submitted value (label is the match text) |
data-keywords | [data-slot="combobox-item"] | Space-separated extra match targets |
aria-selected | [data-slot="combobox-item"] | true seeds the selection from markup |
data-sort | Root | score: rank by match (default: DOM order) |
data-min-length | Root | Characters before typing opens the panel |
name / value | [data-slot="combobox-value"] | The hidden input the form submits |
data-side / data-align | [data-slot="combobox-panel"] | Placement (popover matrix) |