API
Combobox
A filtered select, single or multiple, with a typed input.
Installation
pnpm dlx zazz-ui add comboboxnpx zazz-ui add comboboxyarn dlx zazz-ui add comboboxbunx zazz-ui add comboboxAlso adds fields, badge, kbd, button, popover, select, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.
Examples
Default
- Australia
- Canada
- Japan
- New Zealand
- United States
No matches.
<div data-ui="field" style="--max-w: 96">
<label data-field-slot="label" for="cb-country">Country</label>
<ui-combobox>
<input type="hidden" name="country" data-combobox-slot="value" value="" />
<div data-combobox-slot="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-combobox-slot="trigger"
tabindex="-1"
aria-label="Show options"
></button>
</div>
<div
data-combobox-slot="panel"
data-popover-side="bottom"
data-popover-align="start"
popover="manual"
>
<ul role="listbox" data-combobox-slot="list" aria-label="Countries">
<li
role="option"
data-combobox-slot="item"
data-ui="button"
data-button-variant="ghost"
data-combobox-value="au"
style="--justify: start"
>
Australia
</li>
<li
role="option"
data-combobox-slot="item"
data-ui="button"
data-button-variant="ghost"
data-combobox-value="ca"
style="--justify: start"
>
Canada
</li>
<li
role="option"
data-combobox-slot="item"
data-ui="button"
data-button-variant="ghost"
data-combobox-value="jp"
style="--justify: start"
>
Japan
</li>
<li
role="option"
data-combobox-slot="item"
data-ui="button"
data-button-variant="ghost"
data-combobox-value="nz"
style="--justify: start"
>
New Zealand
</li>
<li
role="option"
data-combobox-slot="item"
data-ui="button"
data-button-variant="ghost"
data-combobox-value="us"
style="--justify: start"
>
United States
</li>
</ul>
<p
data-combobox-slot="empty"
style="
--font-size: var(--font-size-sm);
--leading: var(--leading-sm);
--tracking: var(--tracking-sm);
--text: var(--color-muted-foreground);
"
>
No matches.
</p>
</div>
</ui-combobox>
</div>/**
* combobox.css: Combobox (ui-combobox | [data-ui~="combobox"], [data-combobox-slot~="control"])
*
* @layer variables, components
* @requires _layers.css, _variables.css, popover.css, button.css, fields.css,
* badge.css (--ui-badge-* for the default tag template [data-ui~="badge"]),
* select.css (--ui-option-*, --ui-select-picker-icon-*)
* @uses [popover="manual"]: panel opened/closed by combobox.ts
* @uses anchor-name, anchor-scope, position-anchor: tether panel to the control
* @uses anchor-size(width): match panel width to the control (Baseline 2026)
* @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-popover-side / data-popover-align: maps to --ui-popover-position-* in popover.css
* @uses data-combobox-variant="multiselect": tag variant; combobox.ts clones a
* <template data-combobox-slot="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 */
--ui-combobox-option-gap: var(--ui-field-option-gap);
--ui-combobox-w: var(--ui-select-w);
--ui-combobox-min-w: var(--ui-select-min-w);
--ui-combobox-pl: var(--ui-select-pl);
--ui-combobox-pr: var(--ui-select-pr);
--ui-combobox-border-width: var(--ui-select-border-width);
--ui-combobox-border-style: var(--ui-select-border-style);
--ui-combobox-border-color: var(--ui-select-border-color);
--ui-combobox-rounded: var(--ui-select-rounded);
--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-h: calc(var(--spacing) * 72);
--ui-combobox-shadow: var(--ui-menu-shadow);
/* Control box: the multiselect variant swaps these to wrap into rows */
--ui-combobox-control-items: center;
--ui-combobox-control-flex-wrap: nowrap;
--ui-combobox-control-gap: var(--ui-field-gap);
--ui-combobox-control-h: var(--ui-field-h);
--ui-combobox-control-min-h: auto;
--ui-combobox-control-py: 0;
--ui-combobox-input-min-w: 0;
--ui-combobox-input-min-h: auto;
/* selection tags (multiselect). The remove icon is re-declared: the identical
--search-cancel-mask sits inside ::-webkit-search-cancel-button in
_reset.css, out of reach. */
--ui-combobox-tag-gap: var(--spacing);
--ui-combobox-tag-py: var(--spacing);
--ui-combobox-tag-max-w: 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-rounded: var(--radius-sm);
--ui-combobox-tag-remove-opacity: 0.65;
--ui-combobox-tag-remove-opacity--hover: 1;
}
}
@layer ui.components {
/* ===========================================================================
COMBOBOX: select-look control + anchored filterable panel
- The control shell carries the field surface; the inner input is bare so
nothing double-borders. The panel rides popover.css like autocomplete's.
=========================================================================== */
:where(ui-combobox, [data-ui~="combobox"]) {
anchor-scope: --ui-combobox-trigger;
display: flex;
flex-direction: column;
gap: 1px;
inline-size: var(--ui-combobox-w);
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="control"] {
anchor-name: --ui-combobox-trigger;
display: flex;
flex-wrap: var(--_flex-wrap-resolved, var(--ui-combobox-control-flex-wrap));
align-items: var(--_items-resolved, var(--ui-combobox-control-items));
--_gap-len: var(--_gap-resolved, var(--ui-combobox-control-gap));
--_gap-num: var(--_gap-resolved, var(--ui-combobox-control-gap));
gap: calc(var(--_gap-len) + var(--_gap-num) * var(--spacing));
--_w-len: var(--_w-resolved, var(--ui-combobox-w));
--_w-num: var(--_w-resolved, var(--ui-combobox-w));
inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
--_min-w-len: var(--_min-w-resolved, var(--ui-combobox-min-w));
--_min-w-num: var(--_min-w-resolved, var(--ui-combobox-min-w));
min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
--_h-len: var(--_h-resolved, var(--ui-combobox-control-h));
--_h-num: var(--_h-resolved, var(--ui-combobox-control-h));
block-size: calc(var(--_h-len) + var(--_h-num) * var(--spacing));
--_min-h-len: var(--_min-h-resolved, var(--ui-combobox-control-min-h));
--_min-h-num: var(--_min-h-resolved, var(--ui-combobox-control-min-h));
min-block-size: calc(var(--_min-h-len) + var(--_min-h-num) * var(--spacing));
--_py-len: var(--_py-resolved, var(--ui-combobox-control-py));
--_py-num: var(--_py-resolved, var(--ui-combobox-control-py));
padding-block: calc(var(--_py-len) + var(--_py-num) * var(--spacing));
--_pl-len: var(--_pl-resolved, var(--ui-combobox-pl));
--_pl-num: var(--_pl-resolved, var(--ui-combobox-pl));
padding-inline-start: calc(var(--_pl-len) + var(--_pl-num) * var(--spacing));
--_pr-len: var(--_pr-resolved, var(--ui-combobox-pr));
--_pr-num: var(--_pr-resolved, var(--ui-combobox-pr));
padding-inline-end: calc(var(--_pr-len) + var(--_pr-num) * var(--spacing));
font-size: var(--_font-size-resolved, var(--ui-field-font-size));
line-height: var(--_leading-resolved, var(--ui-field-leading));
color: var(--_text-resolved, var(--ui-field-text));
background-color: var(--_bg-resolved, var(--ui-field-bg));
border-width: var(--_border-width-resolved, var(--ui-combobox-border-width));
border-style: var(--_border-style-resolved, var(--ui-combobox-border-style));
border-color: var(--_border-color-resolved, var(--ui-combobox-border-color));
border-radius: var(--_rounded-resolved, var(--ui-combobox-rounded));
/* Same ring recipe as [data-ui~="select"]: transparent outline twin keeps focus
visible in forced-colors mode */
--_ring-offset-width: 0px;
--_ring-width: 0px;
--_focus-in: max(0, -1 * sign(var(--_focus-ring-w, 1px)));
--_ring-fill: color-mix(in oklch, var(--ui-field-ring-color) var(--ring-opacity), transparent);
/* The ring is published to --_focus-shadow so a --shadow utility keeps it */
--_focus-shadow:
0 0 0 calc(var(--_ring-offset-width) * (1 - var(--_focus-in))) var(--ring-offset-shadow-color),
0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * (1 - var(--_focus-in)))
var(--_ring-fill, var(--ring-shadow-color)),
inset 0 0 0 calc(var(--_ring-offset-width) * var(--_focus-in)) var(--ring-offset-shadow-color),
inset 0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * var(--_focus-in))
var(--_ring-fill, var(--ring-shadow-color));
box-shadow: var(--_focus-shadow, 0 0 #0000), var(--_shadow-resolved, 0 0 #0000);
outline: abs(var(--_focus-ring-w, var(--outline-width))) var(--outline-style) transparent;
outline-offset: calc(
var(--outline-offset) - var(--_focus-in) *
(2 * var(--outline-offset) + abs(var(--_focus-ring-w, var(--outline-width))))
);
transition: var(--default-transition);
}
@media (hover: hover) {
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="control"]:hover {
border-color: var(--ui-field-border-color--hover);
}
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="control"]:focus-within {
border-color: var(--ui-field-border-color--focus-within);
background-color: var(--ui-field-bg--focus-within);
--_ring-offset-width: var(--ring-offset-width);
--_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
outline-color: transparent;
}
:where(ui-combobox, [data-ui~="combobox"])
[data-combobox-slot~="control"]:has(> [data-combobox-slot~="tag"]) {
padding-inline-start: calc(var(--spacing) * 1.5);
}
/* The shell owns the surface. The minimum width makes the multiselect variant
wrap the input onto its own row instead of collapsing. */
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="control"] input {
flex: 1 1 var(--ui-combobox-input-min-w);
min-inline-size: var(--ui-combobox-input-min-w);
min-block-size: var(--ui-combobox-input-min-h);
font: inherit;
color: inherit;
background: none;
border: none;
outline: none;
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="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;
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="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);
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="panel"] {
position-anchor: --ui-combobox-trigger;
/* ungated so the close fade keeps the column layout */
flex-direction: column;
max-block-size: var(--ui-combobox-panel-max-h);
overflow: auto;
box-shadow: var(--ui-combobox-shadow);
}
/* Only display is gated: closed popovers stay display: none */
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="panel"]:popover-open {
display: flex;
}
/* Inline variant (no [popover]): the list renders in flow, always open,
without the floating surface chrome */
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="panel"]:not([popover]) {
display: flex;
box-shadow: none;
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="panel"] {
min-inline-size: anchor-size(width);
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="list"] {
display: flex;
flex-direction: column;
gap: var(--ui-combobox-option-gap);
margin: 0;
padding: 0;
list-style: none;
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="group"] {
display: flex;
flex-direction: column;
gap: var(--ui-combobox-option-gap);
}
/* score ranking writes order -1000..0 on items: keep the label first */
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="group-label"] {
order: -1001;
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="item"] {
cursor: pointer;
}
:where(ui-combobox, [data-ui~="combobox"])
[data-combobox-slot~="item"][data-combobox-state~="highlighted"] {
--_button-bg: var(--ui-button-bg--hover);
--_button-text: var(--ui-button-text--hover);
}
:where(ui-combobox, [data-ui~="combobox"])
[data-combobox-slot~="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;
}
:where(ui-combobox, [data-ui~="combobox"])
[data-combobox-slot~="group"]:not(:has([data-combobox-slot~="item"]:not([hidden]))) {
display: none;
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="empty"] {
display: none;
padding: calc(var(--spacing) * 2);
}
:where(ui-combobox, [data-ui~="combobox"])
[data-combobox-slot~="panel"]:not(:has([data-combobox-slot~="item"]:not([hidden])))
[data-combobox-slot~="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. Tags are stamped from items carrying
aria-selected="true", dressed as a muted badge.
=========================================================================== */
:where(ui-combobox, [data-ui~="combobox"])[data-combobox-variant="multiselect"] {
--ui-combobox-control-flex-wrap: wrap;
--ui-combobox-control-gap: var(--ui-combobox-tag-gap);
--ui-combobox-control-h: auto;
--ui-combobox-control-min-h: var(--ui-field-h);
--ui-combobox-control-py: var(--ui-combobox-tag-py);
--ui-combobox-input-min-w: calc(var(--spacing) * 16);
--ui-combobox-input-min-h: var(--ui-badge-h);
}
/* Overrides badge.css min-width: max-content (same layer, same
specificity, and combobox.css imports after badge.css) */
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="tag"] {
min-inline-size: 0;
max-inline-size: var(--ui-combobox-tag-max-w);
}
/* The label wears its own box because text-overflow ignores a flex
container's own text, and [data-ui~="badge"] is display: flex */
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="tag-label"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="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-rounded);
opacity: var(--ui-combobox-tag-remove-opacity);
cursor: pointer;
transition: var(--default-transition);
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="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;
}
@media (hover: hover) {
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="tag-remove"]:hover {
opacity: var(--ui-combobox-tag-remove-opacity--hover);
}
}
}// 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/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-combobox-slot="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 `[data-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-combobox-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-combobox-slot="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 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 `[data-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-combobox-variant="multiselect"`, `data-combobox-sort="score"`,
* `data-combobox-filter="none"` (another source filters; the engine only navigates),
* `data-combobox-min-length="<n>"`, `data-combobox-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";
/** Default tag markup, cloned per selected value; a `tag-template` overrides it. */
const TAG_MARKUP = '<span data-ui="badge" data-combobox-slot="tag">' +
'<span data-combobox-slot="tag-label"></span>' +
'<button type="button" tabindex="-1" data-combobox-slot="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-combobox-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);
}
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-combobox-slot~="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-combobox-slot~="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-combobox-slot~="trigger"], [data-combobox-slot~="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-combobox-slot~="tag-remove"]');
if (!remove)
return;
// A template that forgets type="button" would otherwise submit the form
event.preventDefault();
const value = remove
.closest('[data-combobox-slot~="tag"]')
?.getAttribute("data-combobox-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-combobox-slot~="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-combobox-slot~="tag"], [data-combobox-state~="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-combobox-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-combobox-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-combobox-variant="multiselect"`.
* @private
*/
#multiselect() {
return this.config("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-combobox-value` when present, the visible label otherwise.
* @private
*/
#itemFormValue(item) {
return item.getAttribute("data-combobox-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-combobox-slot~="value"]:not([data-combobox-state~="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-state~="stamped"]'))
stale.remove();
let anchor = hidden;
for (const value of values.slice(1)) {
const extra = document.createElement("input");
extra.type = "hidden";
extra.setAttribute("data-combobox-slot", "value");
extra.setAttribute("data-combobox-state", "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-combobox-slot="tag-template">` when present, the
* default badge otherwise. A template's content lives in a separate
* fragment, so a blueprint carrying `data-combobox-slot="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-combobox-slot~="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-combobox-slot~="control"]');
if (!control)
return;
for (const stale of control.querySelectorAll('[data-combobox-slot~="tag"]'))
stale.remove();
const blueprint = this.#tagBlueprint();
if (!blueprint)
return;
const removeLabel = this.config("label-remove") ?? "Remove {label}";
const tags = this.#selectedItems().map((item) => {
const label = this.itemValue(item);
const tag = blueprint.cloneNode(true);
tag.setAttribute("data-combobox-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-combobox-slot~="tag"]')) {
const slots = tag.getAttribute("data-combobox-slot");
tag.setAttribute("data-combobox-slot", slots ? `${slots} tag` : "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-combobox-slot~="tag-label"]');
if (text)
text.textContent = label;
else
tag.prepend(document.createTextNode(label));
tag
.querySelector('[data-combobox-slot~="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 };Multiselect
- Design
- Engineering
- Marketing
- Research
- Support
No matches.
<div data-ui="field" style="--max-w: 96">
<label data-field-slot="label" for="cb-tags">Tags</label>
<ui-combobox data-combobox-variant="multiselect" data-combobox-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-combobox-slot="value" value="design" />
<div data-combobox-slot="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-combobox-slot="tag-template">
<span data-ui="badge" data-combobox-slot="tag">
<span data-combobox-slot="tag-label"></span>
<button type="button" tabindex="-1" data-combobox-slot="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-combobox-slot="trigger"
tabindex="-1"
aria-label="Show options"
></button>
</div>
<div
data-combobox-slot="panel"
data-popover-side="bottom"
data-popover-align="start"
popover="manual"
>
<ul role="listbox" aria-multiselectable="true" data-combobox-slot="list" aria-label="Tags">
<li
role="option"
aria-selected="true"
data-combobox-slot="item"
data-ui="button"
data-button-variant="ghost"
data-combobox-value="design"
style="--justify: start"
>
Design
</li>
<li
role="option"
aria-selected="true"
data-combobox-slot="item"
data-ui="button"
data-button-variant="ghost"
data-combobox-value="engineering"
style="--justify: start"
>
Engineering
</li>
<li
role="option"
aria-selected="false"
data-combobox-slot="item"
data-ui="button"
data-button-variant="ghost"
data-combobox-value="marketing"
style="--justify: start"
>
Marketing
</li>
<li
role="option"
aria-selected="false"
data-combobox-slot="item"
data-ui="button"
data-button-variant="ghost"
data-combobox-value="research"
style="--justify: start"
>
Research
</li>
<li
role="option"
aria-selected="false"
data-combobox-slot="item"
data-ui="button"
data-button-variant="ghost"
data-combobox-value="support"
style="--justify: start"
>
Support
</li>
</ul>
<p
data-combobox-slot="empty"
style="
--font-size: var(--font-size-sm);
--leading: var(--leading-sm);
--tracking: var(--tracking-sm);
--text: var(--color-muted-foreground);
"
>
No matches.
</p>
</div>
</ui-combobox>
</div>/**
* combobox.css: Combobox (ui-combobox | [data-ui~="combobox"], [data-combobox-slot~="control"])
*
* @layer variables, components
* @requires _layers.css, _variables.css, popover.css, button.css, fields.css,
* badge.css (--ui-badge-* for the default tag template [data-ui~="badge"]),
* select.css (--ui-option-*, --ui-select-picker-icon-*)
* @uses [popover="manual"]: panel opened/closed by combobox.ts
* @uses anchor-name, anchor-scope, position-anchor: tether panel to the control
* @uses anchor-size(width): match panel width to the control (Baseline 2026)
* @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-popover-side / data-popover-align: maps to --ui-popover-position-* in popover.css
* @uses data-combobox-variant="multiselect": tag variant; combobox.ts clones a
* <template data-combobox-slot="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 */
--ui-combobox-option-gap: var(--ui-field-option-gap);
--ui-combobox-w: var(--ui-select-w);
--ui-combobox-min-w: var(--ui-select-min-w);
--ui-combobox-pl: var(--ui-select-pl);
--ui-combobox-pr: var(--ui-select-pr);
--ui-combobox-border-width: var(--ui-select-border-width);
--ui-combobox-border-style: var(--ui-select-border-style);
--ui-combobox-border-color: var(--ui-select-border-color);
--ui-combobox-rounded: var(--ui-select-rounded);
--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-h: calc(var(--spacing) * 72);
--ui-combobox-shadow: var(--ui-menu-shadow);
/* Control box: the multiselect variant swaps these to wrap into rows */
--ui-combobox-control-items: center;
--ui-combobox-control-flex-wrap: nowrap;
--ui-combobox-control-gap: var(--ui-field-gap);
--ui-combobox-control-h: var(--ui-field-h);
--ui-combobox-control-min-h: auto;
--ui-combobox-control-py: 0;
--ui-combobox-input-min-w: 0;
--ui-combobox-input-min-h: auto;
/* selection tags (multiselect). The remove icon is re-declared: the identical
--search-cancel-mask sits inside ::-webkit-search-cancel-button in
_reset.css, out of reach. */
--ui-combobox-tag-gap: var(--spacing);
--ui-combobox-tag-py: var(--spacing);
--ui-combobox-tag-max-w: 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-rounded: var(--radius-sm);
--ui-combobox-tag-remove-opacity: 0.65;
--ui-combobox-tag-remove-opacity--hover: 1;
}
}
@layer ui.components {
/* ===========================================================================
COMBOBOX: select-look control + anchored filterable panel
- The control shell carries the field surface; the inner input is bare so
nothing double-borders. The panel rides popover.css like autocomplete's.
=========================================================================== */
:where(ui-combobox, [data-ui~="combobox"]) {
anchor-scope: --ui-combobox-trigger;
display: flex;
flex-direction: column;
gap: 1px;
inline-size: var(--ui-combobox-w);
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="control"] {
anchor-name: --ui-combobox-trigger;
display: flex;
flex-wrap: var(--_flex-wrap-resolved, var(--ui-combobox-control-flex-wrap));
align-items: var(--_items-resolved, var(--ui-combobox-control-items));
--_gap-len: var(--_gap-resolved, var(--ui-combobox-control-gap));
--_gap-num: var(--_gap-resolved, var(--ui-combobox-control-gap));
gap: calc(var(--_gap-len) + var(--_gap-num) * var(--spacing));
--_w-len: var(--_w-resolved, var(--ui-combobox-w));
--_w-num: var(--_w-resolved, var(--ui-combobox-w));
inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
--_min-w-len: var(--_min-w-resolved, var(--ui-combobox-min-w));
--_min-w-num: var(--_min-w-resolved, var(--ui-combobox-min-w));
min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
--_h-len: var(--_h-resolved, var(--ui-combobox-control-h));
--_h-num: var(--_h-resolved, var(--ui-combobox-control-h));
block-size: calc(var(--_h-len) + var(--_h-num) * var(--spacing));
--_min-h-len: var(--_min-h-resolved, var(--ui-combobox-control-min-h));
--_min-h-num: var(--_min-h-resolved, var(--ui-combobox-control-min-h));
min-block-size: calc(var(--_min-h-len) + var(--_min-h-num) * var(--spacing));
--_py-len: var(--_py-resolved, var(--ui-combobox-control-py));
--_py-num: var(--_py-resolved, var(--ui-combobox-control-py));
padding-block: calc(var(--_py-len) + var(--_py-num) * var(--spacing));
--_pl-len: var(--_pl-resolved, var(--ui-combobox-pl));
--_pl-num: var(--_pl-resolved, var(--ui-combobox-pl));
padding-inline-start: calc(var(--_pl-len) + var(--_pl-num) * var(--spacing));
--_pr-len: var(--_pr-resolved, var(--ui-combobox-pr));
--_pr-num: var(--_pr-resolved, var(--ui-combobox-pr));
padding-inline-end: calc(var(--_pr-len) + var(--_pr-num) * var(--spacing));
font-size: var(--_font-size-resolved, var(--ui-field-font-size));
line-height: var(--_leading-resolved, var(--ui-field-leading));
color: var(--_text-resolved, var(--ui-field-text));
background-color: var(--_bg-resolved, var(--ui-field-bg));
border-width: var(--_border-width-resolved, var(--ui-combobox-border-width));
border-style: var(--_border-style-resolved, var(--ui-combobox-border-style));
border-color: var(--_border-color-resolved, var(--ui-combobox-border-color));
border-radius: var(--_rounded-resolved, var(--ui-combobox-rounded));
/* Same ring recipe as [data-ui~="select"]: transparent outline twin keeps focus
visible in forced-colors mode */
--_ring-offset-width: 0px;
--_ring-width: 0px;
--_focus-in: max(0, -1 * sign(var(--_focus-ring-w, 1px)));
--_ring-fill: color-mix(in oklch, var(--ui-field-ring-color) var(--ring-opacity), transparent);
/* The ring is published to --_focus-shadow so a --shadow utility keeps it */
--_focus-shadow:
0 0 0 calc(var(--_ring-offset-width) * (1 - var(--_focus-in))) var(--ring-offset-shadow-color),
0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * (1 - var(--_focus-in)))
var(--_ring-fill, var(--ring-shadow-color)),
inset 0 0 0 calc(var(--_ring-offset-width) * var(--_focus-in)) var(--ring-offset-shadow-color),
inset 0 0 0 calc((var(--_ring-offset-width) + var(--_ring-width)) * var(--_focus-in))
var(--_ring-fill, var(--ring-shadow-color));
box-shadow: var(--_focus-shadow, 0 0 #0000), var(--_shadow-resolved, 0 0 #0000);
outline: abs(var(--_focus-ring-w, var(--outline-width))) var(--outline-style) transparent;
outline-offset: calc(
var(--outline-offset) - var(--_focus-in) *
(2 * var(--outline-offset) + abs(var(--_focus-ring-w, var(--outline-width))))
);
transition: var(--default-transition);
}
@media (hover: hover) {
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="control"]:hover {
border-color: var(--ui-field-border-color--hover);
}
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="control"]:focus-within {
border-color: var(--ui-field-border-color--focus-within);
background-color: var(--ui-field-bg--focus-within);
--_ring-offset-width: var(--ring-offset-width);
--_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
outline-color: transparent;
}
:where(ui-combobox, [data-ui~="combobox"])
[data-combobox-slot~="control"]:has(> [data-combobox-slot~="tag"]) {
padding-inline-start: calc(var(--spacing) * 1.5);
}
/* The shell owns the surface. The minimum width makes the multiselect variant
wrap the input onto its own row instead of collapsing. */
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="control"] input {
flex: 1 1 var(--ui-combobox-input-min-w);
min-inline-size: var(--ui-combobox-input-min-w);
min-block-size: var(--ui-combobox-input-min-h);
font: inherit;
color: inherit;
background: none;
border: none;
outline: none;
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="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;
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="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);
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="panel"] {
position-anchor: --ui-combobox-trigger;
/* ungated so the close fade keeps the column layout */
flex-direction: column;
max-block-size: var(--ui-combobox-panel-max-h);
overflow: auto;
box-shadow: var(--ui-combobox-shadow);
}
/* Only display is gated: closed popovers stay display: none */
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="panel"]:popover-open {
display: flex;
}
/* Inline variant (no [popover]): the list renders in flow, always open,
without the floating surface chrome */
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="panel"]:not([popover]) {
display: flex;
box-shadow: none;
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="panel"] {
min-inline-size: anchor-size(width);
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="list"] {
display: flex;
flex-direction: column;
gap: var(--ui-combobox-option-gap);
margin: 0;
padding: 0;
list-style: none;
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="group"] {
display: flex;
flex-direction: column;
gap: var(--ui-combobox-option-gap);
}
/* score ranking writes order -1000..0 on items: keep the label first */
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="group-label"] {
order: -1001;
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="item"] {
cursor: pointer;
}
:where(ui-combobox, [data-ui~="combobox"])
[data-combobox-slot~="item"][data-combobox-state~="highlighted"] {
--_button-bg: var(--ui-button-bg--hover);
--_button-text: var(--ui-button-text--hover);
}
:where(ui-combobox, [data-ui~="combobox"])
[data-combobox-slot~="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;
}
:where(ui-combobox, [data-ui~="combobox"])
[data-combobox-slot~="group"]:not(:has([data-combobox-slot~="item"]:not([hidden]))) {
display: none;
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="empty"] {
display: none;
padding: calc(var(--spacing) * 2);
}
:where(ui-combobox, [data-ui~="combobox"])
[data-combobox-slot~="panel"]:not(:has([data-combobox-slot~="item"]:not([hidden])))
[data-combobox-slot~="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. Tags are stamped from items carrying
aria-selected="true", dressed as a muted badge.
=========================================================================== */
:where(ui-combobox, [data-ui~="combobox"])[data-combobox-variant="multiselect"] {
--ui-combobox-control-flex-wrap: wrap;
--ui-combobox-control-gap: var(--ui-combobox-tag-gap);
--ui-combobox-control-h: auto;
--ui-combobox-control-min-h: var(--ui-field-h);
--ui-combobox-control-py: var(--ui-combobox-tag-py);
--ui-combobox-input-min-w: calc(var(--spacing) * 16);
--ui-combobox-input-min-h: var(--ui-badge-h);
}
/* Overrides badge.css min-width: max-content (same layer, same
specificity, and combobox.css imports after badge.css) */
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="tag"] {
min-inline-size: 0;
max-inline-size: var(--ui-combobox-tag-max-w);
}
/* The label wears its own box because text-overflow ignores a flex
container's own text, and [data-ui~="badge"] is display: flex */
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="tag-label"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="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-rounded);
opacity: var(--ui-combobox-tag-remove-opacity);
cursor: pointer;
transition: var(--default-transition);
}
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="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;
}
@media (hover: hover) {
:where(ui-combobox, [data-ui~="combobox"]) [data-combobox-slot~="tag-remove"]:hover {
opacity: var(--ui-combobox-tag-remove-opacity--hover);
}
}
}// 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/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-combobox-slot="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 `[data-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-combobox-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-combobox-slot="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 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 `[data-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-combobox-variant="multiselect"`, `data-combobox-sort="score"`,
* `data-combobox-filter="none"` (another source filters; the engine only navigates),
* `data-combobox-min-length="<n>"`, `data-combobox-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";
/** Default tag markup, cloned per selected value; a `tag-template` overrides it. */
const TAG_MARKUP = '<span data-ui="badge" data-combobox-slot="tag">' +
'<span data-combobox-slot="tag-label"></span>' +
'<button type="button" tabindex="-1" data-combobox-slot="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-combobox-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);
}
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-combobox-slot~="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-combobox-slot~="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-combobox-slot~="trigger"], [data-combobox-slot~="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-combobox-slot~="tag-remove"]');
if (!remove)
return;
// A template that forgets type="button" would otherwise submit the form
event.preventDefault();
const value = remove
.closest('[data-combobox-slot~="tag"]')
?.getAttribute("data-combobox-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-combobox-slot~="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-combobox-slot~="tag"], [data-combobox-state~="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-combobox-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-combobox-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-combobox-variant="multiselect"`.
* @private
*/
#multiselect() {
return this.config("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-combobox-value` when present, the visible label otherwise.
* @private
*/
#itemFormValue(item) {
return item.getAttribute("data-combobox-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-combobox-slot~="value"]:not([data-combobox-state~="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-state~="stamped"]'))
stale.remove();
let anchor = hidden;
for (const value of values.slice(1)) {
const extra = document.createElement("input");
extra.type = "hidden";
extra.setAttribute("data-combobox-slot", "value");
extra.setAttribute("data-combobox-state", "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-combobox-slot="tag-template">` when present, the
* default badge otherwise. A template's content lives in a separate
* fragment, so a blueprint carrying `data-combobox-slot="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-combobox-slot~="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-combobox-slot~="control"]');
if (!control)
return;
for (const stale of control.querySelectorAll('[data-combobox-slot~="tag"]'))
stale.remove();
const blueprint = this.#tagBlueprint();
if (!blueprint)
return;
const removeLabel = this.config("label-remove") ?? "Remove {label}";
const tags = this.#selectedItems().map((item) => {
const label = this.itemValue(item);
const tag = blueprint.cloneNode(true);
tag.setAttribute("data-combobox-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-combobox-slot~="tag"]')) {
const slots = tag.getAttribute("data-combobox-slot");
tag.setAttribute("data-combobox-slot", slots ? `${slots} tag` : "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-combobox-slot~="tag-label"]');
if (text)
text.textContent = label;
else
tag.prepend(document.createTextNode(label));
tag
.querySelector('[data-combobox-slot~="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 };Behavior
primitives/combobox/combobox.js
<ui-combobox>: an input restricted to a predefined list. 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-combobox-slot="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 [data-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-combobox-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-combobox-slot="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 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 [data-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-combobox-variant="multiselect", data-combobox-sort="score", data-combobox-filter="none" (another source filters; the engine only navigates), data-combobox-min-length="<n>", data-combobox-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.
Attributes
| Attribute | Kind | Values | Notes |
|---|---|---|---|
data-ui="combobox" | Identity | free value | Identity token in data-ui. |
<ui-combobox> | Tag form | free value | Tag form; styled and, when scripted, registered under this name. |
data-combobox-variant | Preset | multiselect | Preset: a named bundle of values. |
data-combobox-slot | Slot | control empty group group-label item list panel tag tag-label tag-remove tag-template trigger value | Slot: a part inside the primitive. |
data-combobox-state | State | highlighted | State the kit's script writes; read it in selectors, never author it. |
data-combobox-filter | Config | free value | Configuration the kit's script reads. |
data-combobox-keywords | Config | free value | Configuration the kit's script reads. |
data-combobox-label-remove | Config | free value | Configuration the kit's script reads. |
data-combobox-min-length | Config | free value | Configuration the kit's script reads. |
data-combobox-sort | Config | free value | Configuration the kit's script reads. |
data-combobox-value | Config | free value | Configuration the kit's script reads. |
Requires: popover, button, fields, badge, select.
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-combobox-border-color | var(--ui-select-border-color) | — |
--ui-combobox-border-style | var(--ui-select-border-style) | — |
--ui-combobox-border-width | var(--ui-select-border-width) | — |
--ui-combobox-control-flex-wrap | nowrap | — |
--ui-combobox-control-gap | var(--ui-field-gap) | — |
--ui-combobox-control-h | var(--ui-field-h) | — |
--ui-combobox-control-items | center | — |
--ui-combobox-control-min-h | auto | — |
--ui-combobox-control-py | 0 | — |
--ui-combobox-icon-color | var(--ui-select-picker-icon-color) | — |
--ui-combobox-icon-mask | var(--ui-select-picker-icon-mask) | — |
--ui-combobox-icon-size | var(--ui-select-picker-icon-size) | — |
--ui-combobox-input-min-h | auto | — |
--ui-combobox-input-min-w | 0 | — |
--ui-combobox-min-w | var(--ui-select-min-w) | — |
--ui-combobox-option-gap | var(--ui-field-option-gap) | — |
--ui-combobox-panel-max-h | calc(var(--spacing) * 72) | — |
--ui-combobox-pl | var(--ui-select-pl) | — |
--ui-combobox-pr | var(--ui-select-pr) | — |
--ui-combobox-rounded | var(--ui-select-rounded) | — |
--ui-combobox-shadow | var(--ui-menu-shadow) | — |
--ui-combobox-tag-gap | var(--spacing) | — |
--ui-combobox-tag-max-w | 100% | — |
--ui-combobox-tag-py | var(--spacing) | — |
--ui-combobox-tag-remove-color | currentColor | — |
--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-opacity | 0.65 | — |
--ui-combobox-tag-remove-opacity--hover | 1 | hover |
--ui-combobox-tag-remove-rounded | var(--radius-sm) | — |
--ui-combobox-tag-remove-size | var(--ui-badge-icon-size) | — |
--ui-combobox-w | var(--ui-select-w) | — |