Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Autocomplete

A text input with suggestions filtered as you type.

Installation

pnpm dlx zazz-ui add autocomplete
npx zazz-ui add autocomplete
yarn dlx zazz-ui add autocomplete
bunx zazz-ui add autocomplete

Also adds fields, kbd, button, popover, input, select, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.

Examples

Default

  • Apple
  • Banana
  • Blueberry
  • Cherry
  • Grape

No results.

<div data-ui="field" style="--max-w: 96">
  <label data-field-slot="label" for="ac-fruit">Fruit</label>
  <ui-autocomplete>
    <input
      data-ui="input"
      id="ac-fruit"
      name="fruit"
      type="text"
      placeholder="Search fruits..."
      role="combobox"
      aria-expanded="false"
      aria-autocomplete="list"
      autocomplete="off"
    />
    <div
      data-autocomplete-slot="panel"
      data-popover-side="bottom"
      data-popover-align="start"
      popover="manual"
    >
      <ul role="listbox" data-autocomplete-slot="list" aria-label="Fruit suggestions">
        <li
          role="option"
          data-autocomplete-slot="item"
          data-ui="button"
          data-button-variant="ghost"
          data-autocomplete-value="Apple"
          style="--justify: start"
        >
          Apple
        </li>
        <li
          role="option"
          data-autocomplete-slot="item"
          data-ui="button"
          data-button-variant="ghost"
          data-autocomplete-value="Banana"
          style="--justify: start"
        >
          Banana
        </li>
        <li
          role="option"
          data-autocomplete-slot="item"
          data-ui="button"
          data-button-variant="ghost"
          data-autocomplete-value="Blueberry"
          style="--justify: start"
        >
          Blueberry
        </li>
        <li
          role="option"
          data-autocomplete-slot="item"
          data-ui="button"
          data-button-variant="ghost"
          data-autocomplete-value="Cherry"
          style="--justify: start"
        >
          Cherry
        </li>
        <li
          role="option"
          data-autocomplete-slot="item"
          data-ui="button"
          data-button-variant="ghost"
          data-autocomplete-value="Grape"
          style="--justify: start"
        >
          Grape
        </li>
      </ul>
      <p data-autocomplete-slot="empty" style="--text: var(--color-muted-foreground)">
        No results.
      </p>
    </div>
  </ui-autocomplete>
</div>
/**
 * autocomplete.css — Autocomplete (ui-autocomplete | [data-ui~="autocomplete"], [data-autocomplete-slot~="panel"])
 *
 * @layer      variables, components
 * @requires   _layers.css, _variables.css, popover.css, button.css, fields.css,
 *             select.css (--ui-option-*), input.css
 * @uses       [popover="manual"] — panel opened/closed by autocomplete.ts
 * @uses       anchor-name, anchor-scope, position-anchor — tether panel to the input
 * @uses       anchor-size(width) — match panel width to the input (Baseline 2026)
 * @uses       :has() — group + empty-state visibility without JavaScript
 * @uses       data-popover-side / data-popover-align — maps to --ui-popover-position-* in popover.css
 * @tokens     --ui-autocomplete-* (@layer variables); items alias --ui-option-*
 * @see        autocomplete.ts — filtering, ranking, and keyboard behavior
 */

@layer variables {
  :root {
    --ui-autocomplete-panel-max-h: calc(var(--spacing) * 72);
    --ui-autocomplete-shadow: var(--ui-menu-shadow);
  }
}

@layer ui.components {
  /* ===========================================================================
  AUTOCOMPLETE — input + anchored suggestion panel
  - The input is a plain [data-ui~="input"] (the no-JS baseline); the panel is a
    manual popover the script owns, riding popover.css's surface + placement.
  - Items are ghost buttons (--ui-option-*), matching a select's options.
  =========================================================================== */

  :where(ui-autocomplete, [data-ui~="autocomplete"]) {
    anchor-scope: --ui-autocomplete-trigger;
    display: block;
    inline-size: 100%;
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"]) input:where([role="combobox"]) {
    anchor-name: --ui-autocomplete-trigger;
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"]) [data-autocomplete-slot~="panel"] {
    position-anchor: --ui-autocomplete-trigger;
    /* ungated so the close fade keeps the column layout */
    flex-direction: column;
    max-block-size: var(--_max-h-resolved, var(--ui-autocomplete-panel-max-h));
    overflow: auto;
    box-shadow: var(--_shadow-resolved, var(--ui-autocomplete-shadow));
  }

  /* Only display is gated: closed popovers stay display: none */
  :where(ui-autocomplete, [data-ui~="autocomplete"])
    [data-autocomplete-slot~="panel"]:popover-open {
    display: flex;
  }

  /* Inline variant (no [popover]): the list renders in flow, always open,
     without the floating surface chrome */
  :where(ui-autocomplete, [data-ui~="autocomplete"])
    [data-autocomplete-slot~="panel"]:not([popover]) {
    display: flex;
    box-shadow: none;
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"]) [data-autocomplete-slot~="panel"] {
    min-inline-size: anchor-size(width);
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"]) [data-autocomplete-slot~="list"] {
    display: flex;
    flex-direction: column;
    gap: 1px;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"]) [data-autocomplete-slot~="group"] {
    display: flex;
    flex-direction: column;
    gap: 1px;
  }

  /* score ranking writes order -1000..0 on items: keep the label first */
  :where(ui-autocomplete, [data-ui~="autocomplete"]) [data-autocomplete-slot~="group-label"] {
    order: -1001;
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"]) [data-autocomplete-slot~="item"] {
    cursor: pointer;
  }

  /* The ghost-button variant writes the --_button-* privates, so the
     highlight writes the same ones (a hook would lose to the variant). */
  :where(ui-autocomplete, [data-ui~="autocomplete"])
    [data-autocomplete-slot~="item"][data-autocomplete-state~="highlighted"] {
    --_button-bg: var(--ui-button-bg--hover);
    --_button-text: var(--ui-button-text--hover);
  }

  /* a group with no visible items disappears, label and all */
  :where(ui-autocomplete, [data-ui~="autocomplete"])
    [data-autocomplete-slot~="group"]:not(:has([data-autocomplete-slot~="item"]:not([hidden]))) {
    display: none;
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"]) [data-autocomplete-slot~="empty"] {
    display: none;
    padding: calc(var(--spacing) * 2);
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"])
    [data-autocomplete-slot~="panel"]:not(:has([data-autocomplete-slot~="item"]:not([hidden])))
    [data-autocomplete-slot~="empty"] {
    display: block;
  }
}
// primitives/select/multiselect.js
"use strict";
/**
 * @fileoverview `<ui-multiselect>`: a multi-select styled as a dropdown.
 * @description Light-DOM custom element that progressively enhances a real
 * `<select multiple data-ui="select">`. `appearance: base-select` does not
 * apply to multi-selects in stable engines, so the script hides the
 * select (which remains the form value carrier and source of truth) and
 * stamps a `[data-ui~="select"]`-look trigger button plus an anchored popover of
 * checkbox rows projected from the options. Checkbox changes write back to
 * `option.selected` and re-dispatch `change` on the select; external writes
 * and form resets flow the other way. Without JavaScript the native
 * multi-select listbox renders: fully functional and accessible.
 *
 * The trigger reads `<first selection> (+N more)`.
 *
 * Attributes on `<ui-multiselect>`:
 * - `data-multiselect-placeholder`: trigger text when nothing is selected.
 * - `data-multiselect-label-more`: overflow template, default `"(+{n} more)"`.
 * - `data-multiselect-side` / `data-multiselect-align`: forwarded to the stamped panel as `data-popover-*` (popover
 *   placement matrix).
 *
 * Stamped parts: `multiselect-trigger` (a `[data-ui~="select"]`-identity button),
 * `multiselect-icon` (chevron), `multiselect-panel` (`[popover]`),
 * `multiselect-option` (label + checkbox per option).
 */
import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";
import { effect, state } from "../../base/signals.js";
/**
 * @description Formats the trigger label from the selected option labels.
 *
 * @param labels - Labels of the selected options, in DOM order.
 * @param placeholder - Text for an empty selection.
 * @param moreTemplate - Overflow template; `{n}` is the remaining count.
 * @returns The trigger text.
 */
function resolveTriggerLabel(labels, placeholder, moreTemplate) {
    if (labels.length === 0)
        return placeholder;
    if (labels.length === 1)
        return labels[0];
    return `${labels[0]} ${moreTemplate.replace("{n}", String(labels.length - 1))}`;
}
let multiselectIdCounter = 0;
class UiMultiselect extends ZazzElement {
    #stamped = [];
    #select = null;
    setup(signal) {
        const select = this.querySelector("select[multiple]");
        if (!(select instanceof HTMLSelectElement))
            return;
        this.#select = select;
        const placeholder = this.getAttribute("data-multiselect-placeholder") ?? "Select…";
        const moreTemplate = this.getAttribute("data-multiselect-label-more") ?? "(+{n} more)";
        const { trigger, label, panel, checkboxes } = this.#stamp(select);
        select.setAttribute("data-multiselect-state", "enhanced");
        const selectedLabels = () => Array.from(select.selectedOptions).map((option) => option.label);
        const selection = state(selectedLabels());
        // Checkbox → option: the select stays the source of truth
        checkboxes.forEach((checkbox, index) => {
            checkbox.addEventListener("change", () => {
                const option = select.options[index];
                if (option)
                    option.selected = checkbox.checked;
                selection.set(selectedLabels());
                select.dispatchEvent(new Event("change", { bubbles: true }));
            }, { signal });
        });
        // External writes and form resets → re-read (reset applies after the event)
        select.addEventListener("change", () => selection.set(selectedLabels()), { signal });
        select.form?.addEventListener("reset", () => queueMicrotask(() => selection.set(selectedLabels())), { signal });
        // Mirror popover open state onto the trigger's aria-expanded
        panel.addEventListener("toggle", (event) => {
            trigger.setAttribute("aria-expanded", String(event.newState === "open"));
        }, { signal });
        // Output adapter: trigger label + row checked states together
        effect(() => {
            selection.get();
            label.textContent = resolveTriggerLabel(selectedLabels(), placeholder, moreTemplate);
            checkboxes.forEach((checkbox, index) => {
                const option = select.options[index];
                if (option && checkbox.checked !== option.selected)
                    checkbox.checked = option.selected;
            });
        }, { signal });
    }
    teardown() {
        for (const node of this.#stamped)
            node.remove();
        this.#stamped = [];
        this.#select?.removeAttribute("data-multiselect-state");
        this.#select = null;
    }
    /**
     * @description Builds the trigger button and checkbox panel from the
     * select's options and inserts them after it.
     *
     * @param select - The enhanced select.
     * @returns The stamped parts and the checkbox list (option order).
     * @private
     */
    #stamp(select) {
        const panelId = `ui-multiselect-panel-${++multiselectIdCounter}`;
        const trigger = document.createElement("button");
        trigger.type = "button";
        trigger.dataset.ui = "select";
        trigger.setAttribute("data-multiselect-slot", "trigger");
        trigger.setAttribute("popovertarget", panelId);
        trigger.setAttribute("aria-expanded", "false");
        const label = document.createElement("span");
        label.setAttribute("data-multiselect-slot", "label");
        const icon = document.createElement("span");
        icon.setAttribute("data-multiselect-slot", "icon");
        icon.setAttribute("aria-hidden", "true");
        trigger.append(label, icon);
        const panel = document.createElement("div");
        panel.id = panelId;
        panel.setAttribute("data-multiselect-slot", "panel");
        panel.setAttribute("popover", "auto");
        const side = this.getAttribute("data-multiselect-side");
        const align = this.getAttribute("data-multiselect-align");
        if (side)
            panel.setAttribute("data-popover-side", side);
        if (align)
            panel.setAttribute("data-popover-align", align);
        const checkboxes = [];
        for (const [index, option] of Array.from(select.options).entries()) {
            const row = document.createElement("label");
            row.setAttribute("data-multiselect-slot", "option");
            const checkbox = document.createElement("input");
            checkbox.type = "checkbox";
            // an id, not a name: the select submits, and Chrome flags a field with neither
            checkbox.id = `${panelId}-${index}`;
            checkbox.checked = option.selected;
            checkbox.disabled = option.disabled;
            row.append(checkbox, document.createTextNode(option.label));
            panel.append(row);
            checkboxes.push(checkbox);
        }
        select.after(trigger, panel);
        this.#stamped = [trigger, panel];
        return { trigger, label, panel, checkboxes };
    }
}
defineZazzElement("ui-multiselect", UiMultiselect);
export { UiMultiselect, resolveTriggerLabel };

// primitives/autocomplete/autocomplete.js
"use strict";
/**
 * @fileoverview `<ui-autocomplete>`: an input that suggests options as you type.
 * @description Light-DOM custom element on the shared typeahead engine
 * (`base/typeahead.ts`). The visible input is the form value (free text is
 * allowed) and the anchored `popover="manual"` panel suggests matches ranked
 * by the vendored cmdk scorer. Committing a suggestion (Enter or click) fills
 * the input; without JavaScript the markup degrades to a plain `[data-ui~="input"]`.
 *
 * Attributes on the root:
 * - `data-autocomplete-sort="score"`: re-rank visually by match score (default: DOM order).
 * - `data-autocomplete-filter="none"`: show every item; another source filters.
 * - `data-autocomplete-min-length="<n>"`: query length before the panel opens (default 0).
 *
 * Parts: `autocomplete-panel` (popover="manual"), `autocomplete-list`
 * ([role="listbox"]), `autocomplete-item` ([role="option"], `data-autocomplete-value`,
 * optional `data-autocomplete-keywords`), `autocomplete-group` / `autocomplete-group-label`,
 * `autocomplete-empty`.
 */
import { TypeaheadElement } from "../../base/typeahead.js";
import { defineZazzElement } from "../../base/zazz-element.js";
class UiAutocomplete extends TypeaheadElement {
    slotPrefix = "autocomplete";
    /**
     * @description Committing a suggestion fills the visible input (which is
     * the form value), closes the panel, and returns to typing position.
     *
     * @param item - The committed option.
     */
    commit(item, _source) {
        const input = this.searchInput;
        if (!input)
            return;
        input.value = this.itemValue(item);
        this.query.set(input.value);
        this.open.set(false);
        input.focus();
        input.dispatchEvent(new Event("change", { bubbles: true }));
    }
}
defineZazzElement("ui-autocomplete", UiAutocomplete);
export { UiAutocomplete };

Groups

  • Fruit
    Apple
    Cherry
  • Vegetables
    Carrot
    Cauliflower

No results.

<div data-ui="field" style="--max-w: 96">
  <label data-field-slot="label" for="ac-produce">Produce</label>
  <ui-autocomplete data-autocomplete-sort="score">
    <input
      data-ui="input"
      id="ac-produce"
      name="produce"
      type="text"
      placeholder="Search produce..."
      role="combobox"
      aria-expanded="false"
      aria-autocomplete="list"
      autocomplete="off"
    />
    <div
      data-autocomplete-slot="panel"
      data-popover-side="bottom"
      data-popover-align="start"
      popover="manual"
    >
      <ul role="listbox" data-autocomplete-slot="list" aria-label="Produce suggestions">
        <li role="group" data-autocomplete-slot="group" aria-labelledby="ac-group-fruit">
          <span
            data-autocomplete-slot="group-label"
            id="ac-group-fruit"
            style="
              --display: inline-flex;
              --p: 2;
              --font-size: var(--font-size-eyebrow);
              --font-weight: strong;
              --font-weight: var(--font-weight-eyebrow);
              --leading: var(--leading-eyebrow);
              --tracking: var(--tracking-eyebrow);
              --text-transform: uppercase;
              --text: var(--color-muted-foreground);
            "
            >Fruit</span
          >
          <div
            role="option"
            data-autocomplete-slot="item"
            data-ui="button"
            data-button-variant="ghost"
            data-autocomplete-value="Apple"
            data-autocomplete-keywords="fruit red"
            style="--justify: start"
          >
            Apple
          </div>
          <div
            role="option"
            data-autocomplete-slot="item"
            data-ui="button"
            data-button-variant="ghost"
            data-autocomplete-value="Cherry"
            data-autocomplete-keywords="fruit red"
            style="--justify: start"
          >
            Cherry
          </div>
        </li>
        <li role="group" data-autocomplete-slot="group" aria-labelledby="ac-group-veg">
          <span
            data-autocomplete-slot="group-label"
            id="ac-group-veg"
            style="
              --display: inline-flex;
              --p: 2;
              --font-size: var(--font-size-eyebrow);
              --font-weight: strong;
              --font-weight: var(--font-weight-eyebrow);
              --leading: var(--leading-eyebrow);
              --tracking: var(--tracking-eyebrow);
              --text-transform: uppercase;
              --text: var(--color-muted-foreground);
            "
            >Vegetables</span
          >
          <div
            role="option"
            data-autocomplete-slot="item"
            data-ui="button"
            data-button-variant="ghost"
            data-autocomplete-value="Carrot"
            data-autocomplete-keywords="vegetable orange"
            style="--justify: start"
          >
            Carrot
          </div>
          <div
            role="option"
            data-autocomplete-slot="item"
            data-ui="button"
            data-button-variant="ghost"
            data-autocomplete-value="Cauliflower"
            data-autocomplete-keywords="vegetable white"
            style="--justify: start"
          >
            Cauliflower
          </div>
        </li>
      </ul>
      <p data-autocomplete-slot="empty" style="--text: var(--color-muted-foreground)">
        No results.
      </p>
    </div>
  </ui-autocomplete>
</div>
/**
 * autocomplete.css — Autocomplete (ui-autocomplete | [data-ui~="autocomplete"], [data-autocomplete-slot~="panel"])
 *
 * @layer      variables, components
 * @requires   _layers.css, _variables.css, popover.css, button.css, fields.css,
 *             select.css (--ui-option-*), input.css
 * @uses       [popover="manual"] — panel opened/closed by autocomplete.ts
 * @uses       anchor-name, anchor-scope, position-anchor — tether panel to the input
 * @uses       anchor-size(width) — match panel width to the input (Baseline 2026)
 * @uses       :has() — group + empty-state visibility without JavaScript
 * @uses       data-popover-side / data-popover-align — maps to --ui-popover-position-* in popover.css
 * @tokens     --ui-autocomplete-* (@layer variables); items alias --ui-option-*
 * @see        autocomplete.ts — filtering, ranking, and keyboard behavior
 */

@layer variables {
  :root {
    --ui-autocomplete-panel-max-h: calc(var(--spacing) * 72);
    --ui-autocomplete-shadow: var(--ui-menu-shadow);
  }
}

@layer ui.components {
  /* ===========================================================================
  AUTOCOMPLETE — input + anchored suggestion panel
  - The input is a plain [data-ui~="input"] (the no-JS baseline); the panel is a
    manual popover the script owns, riding popover.css's surface + placement.
  - Items are ghost buttons (--ui-option-*), matching a select's options.
  =========================================================================== */

  :where(ui-autocomplete, [data-ui~="autocomplete"]) {
    anchor-scope: --ui-autocomplete-trigger;
    display: block;
    inline-size: 100%;
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"]) input:where([role="combobox"]) {
    anchor-name: --ui-autocomplete-trigger;
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"]) [data-autocomplete-slot~="panel"] {
    position-anchor: --ui-autocomplete-trigger;
    /* ungated so the close fade keeps the column layout */
    flex-direction: column;
    max-block-size: var(--_max-h-resolved, var(--ui-autocomplete-panel-max-h));
    overflow: auto;
    box-shadow: var(--_shadow-resolved, var(--ui-autocomplete-shadow));
  }

  /* Only display is gated: closed popovers stay display: none */
  :where(ui-autocomplete, [data-ui~="autocomplete"])
    [data-autocomplete-slot~="panel"]:popover-open {
    display: flex;
  }

  /* Inline variant (no [popover]): the list renders in flow, always open,
     without the floating surface chrome */
  :where(ui-autocomplete, [data-ui~="autocomplete"])
    [data-autocomplete-slot~="panel"]:not([popover]) {
    display: flex;
    box-shadow: none;
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"]) [data-autocomplete-slot~="panel"] {
    min-inline-size: anchor-size(width);
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"]) [data-autocomplete-slot~="list"] {
    display: flex;
    flex-direction: column;
    gap: 1px;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"]) [data-autocomplete-slot~="group"] {
    display: flex;
    flex-direction: column;
    gap: 1px;
  }

  /* score ranking writes order -1000..0 on items: keep the label first */
  :where(ui-autocomplete, [data-ui~="autocomplete"]) [data-autocomplete-slot~="group-label"] {
    order: -1001;
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"]) [data-autocomplete-slot~="item"] {
    cursor: pointer;
  }

  /* The ghost-button variant writes the --_button-* privates, so the
     highlight writes the same ones (a hook would lose to the variant). */
  :where(ui-autocomplete, [data-ui~="autocomplete"])
    [data-autocomplete-slot~="item"][data-autocomplete-state~="highlighted"] {
    --_button-bg: var(--ui-button-bg--hover);
    --_button-text: var(--ui-button-text--hover);
  }

  /* a group with no visible items disappears, label and all */
  :where(ui-autocomplete, [data-ui~="autocomplete"])
    [data-autocomplete-slot~="group"]:not(:has([data-autocomplete-slot~="item"]:not([hidden]))) {
    display: none;
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"]) [data-autocomplete-slot~="empty"] {
    display: none;
    padding: calc(var(--spacing) * 2);
  }

  :where(ui-autocomplete, [data-ui~="autocomplete"])
    [data-autocomplete-slot~="panel"]:not(:has([data-autocomplete-slot~="item"]:not([hidden])))
    [data-autocomplete-slot~="empty"] {
    display: block;
  }
}
// primitives/select/multiselect.js
"use strict";
/**
 * @fileoverview `<ui-multiselect>`: a multi-select styled as a dropdown.
 * @description Light-DOM custom element that progressively enhances a real
 * `<select multiple data-ui="select">`. `appearance: base-select` does not
 * apply to multi-selects in stable engines, so the script hides the
 * select (which remains the form value carrier and source of truth) and
 * stamps a `[data-ui~="select"]`-look trigger button plus an anchored popover of
 * checkbox rows projected from the options. Checkbox changes write back to
 * `option.selected` and re-dispatch `change` on the select; external writes
 * and form resets flow the other way. Without JavaScript the native
 * multi-select listbox renders: fully functional and accessible.
 *
 * The trigger reads `<first selection> (+N more)`.
 *
 * Attributes on `<ui-multiselect>`:
 * - `data-multiselect-placeholder`: trigger text when nothing is selected.
 * - `data-multiselect-label-more`: overflow template, default `"(+{n} more)"`.
 * - `data-multiselect-side` / `data-multiselect-align`: forwarded to the stamped panel as `data-popover-*` (popover
 *   placement matrix).
 *
 * Stamped parts: `multiselect-trigger` (a `[data-ui~="select"]`-identity button),
 * `multiselect-icon` (chevron), `multiselect-panel` (`[popover]`),
 * `multiselect-option` (label + checkbox per option).
 */
import { ZazzElement, defineZazzElement } from "../../base/zazz-element.js";
import { effect, state } from "../../base/signals.js";
/**
 * @description Formats the trigger label from the selected option labels.
 *
 * @param labels - Labels of the selected options, in DOM order.
 * @param placeholder - Text for an empty selection.
 * @param moreTemplate - Overflow template; `{n}` is the remaining count.
 * @returns The trigger text.
 */
function resolveTriggerLabel(labels, placeholder, moreTemplate) {
    if (labels.length === 0)
        return placeholder;
    if (labels.length === 1)
        return labels[0];
    return `${labels[0]} ${moreTemplate.replace("{n}", String(labels.length - 1))}`;
}
let multiselectIdCounter = 0;
class UiMultiselect extends ZazzElement {
    #stamped = [];
    #select = null;
    setup(signal) {
        const select = this.querySelector("select[multiple]");
        if (!(select instanceof HTMLSelectElement))
            return;
        this.#select = select;
        const placeholder = this.getAttribute("data-multiselect-placeholder") ?? "Select…";
        const moreTemplate = this.getAttribute("data-multiselect-label-more") ?? "(+{n} more)";
        const { trigger, label, panel, checkboxes } = this.#stamp(select);
        select.setAttribute("data-multiselect-state", "enhanced");
        const selectedLabels = () => Array.from(select.selectedOptions).map((option) => option.label);
        const selection = state(selectedLabels());
        // Checkbox → option: the select stays the source of truth
        checkboxes.forEach((checkbox, index) => {
            checkbox.addEventListener("change", () => {
                const option = select.options[index];
                if (option)
                    option.selected = checkbox.checked;
                selection.set(selectedLabels());
                select.dispatchEvent(new Event("change", { bubbles: true }));
            }, { signal });
        });
        // External writes and form resets → re-read (reset applies after the event)
        select.addEventListener("change", () => selection.set(selectedLabels()), { signal });
        select.form?.addEventListener("reset", () => queueMicrotask(() => selection.set(selectedLabels())), { signal });
        // Mirror popover open state onto the trigger's aria-expanded
        panel.addEventListener("toggle", (event) => {
            trigger.setAttribute("aria-expanded", String(event.newState === "open"));
        }, { signal });
        // Output adapter: trigger label + row checked states together
        effect(() => {
            selection.get();
            label.textContent = resolveTriggerLabel(selectedLabels(), placeholder, moreTemplate);
            checkboxes.forEach((checkbox, index) => {
                const option = select.options[index];
                if (option && checkbox.checked !== option.selected)
                    checkbox.checked = option.selected;
            });
        }, { signal });
    }
    teardown() {
        for (const node of this.#stamped)
            node.remove();
        this.#stamped = [];
        this.#select?.removeAttribute("data-multiselect-state");
        this.#select = null;
    }
    /**
     * @description Builds the trigger button and checkbox panel from the
     * select's options and inserts them after it.
     *
     * @param select - The enhanced select.
     * @returns The stamped parts and the checkbox list (option order).
     * @private
     */
    #stamp(select) {
        const panelId = `ui-multiselect-panel-${++multiselectIdCounter}`;
        const trigger = document.createElement("button");
        trigger.type = "button";
        trigger.dataset.ui = "select";
        trigger.setAttribute("data-multiselect-slot", "trigger");
        trigger.setAttribute("popovertarget", panelId);
        trigger.setAttribute("aria-expanded", "false");
        const label = document.createElement("span");
        label.setAttribute("data-multiselect-slot", "label");
        const icon = document.createElement("span");
        icon.setAttribute("data-multiselect-slot", "icon");
        icon.setAttribute("aria-hidden", "true");
        trigger.append(label, icon);
        const panel = document.createElement("div");
        panel.id = panelId;
        panel.setAttribute("data-multiselect-slot", "panel");
        panel.setAttribute("popover", "auto");
        const side = this.getAttribute("data-multiselect-side");
        const align = this.getAttribute("data-multiselect-align");
        if (side)
            panel.setAttribute("data-popover-side", side);
        if (align)
            panel.setAttribute("data-popover-align", align);
        const checkboxes = [];
        for (const [index, option] of Array.from(select.options).entries()) {
            const row = document.createElement("label");
            row.setAttribute("data-multiselect-slot", "option");
            const checkbox = document.createElement("input");
            checkbox.type = "checkbox";
            // an id, not a name: the select submits, and Chrome flags a field with neither
            checkbox.id = `${panelId}-${index}`;
            checkbox.checked = option.selected;
            checkbox.disabled = option.disabled;
            row.append(checkbox, document.createTextNode(option.label));
            panel.append(row);
            checkboxes.push(checkbox);
        }
        select.after(trigger, panel);
        this.#stamped = [trigger, panel];
        return { trigger, label, panel, checkboxes };
    }
}
defineZazzElement("ui-multiselect", UiMultiselect);
export { UiMultiselect, resolveTriggerLabel };

// primitives/autocomplete/autocomplete.js
"use strict";
/**
 * @fileoverview `<ui-autocomplete>`: an input that suggests options as you type.
 * @description Light-DOM custom element on the shared typeahead engine
 * (`base/typeahead.ts`). The visible input is the form value (free text is
 * allowed) and the anchored `popover="manual"` panel suggests matches ranked
 * by the vendored cmdk scorer. Committing a suggestion (Enter or click) fills
 * the input; without JavaScript the markup degrades to a plain `[data-ui~="input"]`.
 *
 * Attributes on the root:
 * - `data-autocomplete-sort="score"`: re-rank visually by match score (default: DOM order).
 * - `data-autocomplete-filter="none"`: show every item; another source filters.
 * - `data-autocomplete-min-length="<n>"`: query length before the panel opens (default 0).
 *
 * Parts: `autocomplete-panel` (popover="manual"), `autocomplete-list`
 * ([role="listbox"]), `autocomplete-item` ([role="option"], `data-autocomplete-value`,
 * optional `data-autocomplete-keywords`), `autocomplete-group` / `autocomplete-group-label`,
 * `autocomplete-empty`.
 */
import { TypeaheadElement } from "../../base/typeahead.js";
import { defineZazzElement } from "../../base/zazz-element.js";
class UiAutocomplete extends TypeaheadElement {
    slotPrefix = "autocomplete";
    /**
     * @description Committing a suggestion fills the visible input (which is
     * the form value), closes the panel, and returns to typing position.
     *
     * @param item - The committed option.
     */
    commit(item, _source) {
        const input = this.searchInput;
        if (!input)
            return;
        input.value = this.itemValue(item);
        this.query.set(input.value);
        this.open.set(false);
        input.focus();
        input.dispatchEvent(new Event("change", { bubbles: true }));
    }
}
defineZazzElement("ui-autocomplete", UiAutocomplete);
export { UiAutocomplete };

Behavior

primitives/autocomplete/autocomplete.js

<ui-autocomplete>: an input that suggests options as you type. Light-DOM custom element on the shared typeahead engine (base/typeahead.ts). The visible input is the form value (free text is allowed) and the anchored popover="manual" panel suggests matches ranked by the vendored cmdk scorer. Committing a suggestion (Enter or click) fills the input; without JavaScript the markup degrades to a plain [data-ui~="input"].

Attributes on the root:

  • data-autocomplete-sort="score": re-rank visually by match score (default: DOM order).
  • data-autocomplete-filter="none": show every item; another source filters.
  • data-autocomplete-min-length="<n>": query length before the panel opens (default 0).

Parts: autocomplete-panel (popover="manual"), autocomplete-list ([role="listbox"]), autocomplete-item ([role="option"], data-autocomplete-value, optional data-autocomplete-keywords), autocomplete-group / autocomplete-group-label, autocomplete-empty.

Attributes

autocomplete attributes
AttributeKindValuesNotes
data-ui="autocomplete"Identityfree valueIdentity token in data-ui.
<ui-autocomplete>Tag formfree valueTag form; styled and, when scripted, registered under this name.
data-autocomplete-slotSlotempty group group-label item list panelSlot: a part inside the primitive.
data-autocomplete-stateStatehighlightedState the kit's script writes; read it in selectors, never author it.
data-autocomplete-filterConfigfree valueConfiguration the kit's script reads.
data-autocomplete-keywordsConfigfree valueConfiguration the kit's script reads.
data-autocomplete-min-lengthConfigfree valueConfiguration the kit's script reads.
data-autocomplete-sortConfigfree valueConfiguration the kit's script reads.
data-autocomplete-valueConfigfree valueConfiguration the kit's script reads.

Requires: popover, button, fields, select, input.

Hooks

Set a hook on :root, on a subtree, or inline on one element. A state hook (--…--hover) themes that state only; the primitive keeps its other states.

autocomplete hooks
HookDefaultState
--ui-autocomplete-panel-max-hcalc(var(--spacing) * 72)—
--ui-autocomplete-shadowvar(--ui-menu-shadow)—