API
Autocomplete
A text input with suggestions filtered as you type.
Installation
pnpm dlx zazz-ui add autocompletenpx zazz-ui add autocompleteyarn dlx zazz-ui add autocompletebunx zazz-ui add autocompleteAlso 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
AppleCherry
-
Vegetables
CarrotCauliflower
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
| Attribute | Kind | Values | Notes |
|---|---|---|---|
data-ui="autocomplete" | Identity | free value | Identity token in data-ui. |
<ui-autocomplete> | Tag form | free value | Tag form; styled and, when scripted, registered under this name. |
data-autocomplete-slot | Slot | empty group group-label item list panel | Slot: a part inside the primitive. |
data-autocomplete-state | State | highlighted | State the kit's script writes; read it in selectors, never author it. |
data-autocomplete-filter | Config | free value | Configuration the kit's script reads. |
data-autocomplete-keywords | Config | free value | Configuration the kit's script reads. |
data-autocomplete-min-length | Config | free value | Configuration the kit's script reads. |
data-autocomplete-sort | Config | free value | Configuration the kit's script reads. |
data-autocomplete-value | Config | free value | Configuration 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.
| Hook | Default | State |
|---|---|---|
--ui-autocomplete-panel-max-h | calc(var(--spacing) * 72) | — |
--ui-autocomplete-shadow | var(--ui-menu-shadow) | — |