API
Select
The native `<select>`, restyled, and a multiselect with tags.
Installation
pnpm dlx zazz-ui add selectnpx zazz-ui add selectyarn dlx zazz-ui add selectbunx zazz-ui add selectAlso adds fields, kbd, button, popover, which it depends on. Run zazz-ui init first if the project has no zazz/ folder yet.
Examples
Default
<div data-ui="field">
<label data-field-slot="label" for="f-sort">Sort by</label>
<select data-ui="select" id="f-sort" name="sort">
<button>
<selectedcontent></selectedcontent>
</button>
<!-- Keep label in optgroup as a fallback -->
<optgroup
label="Sort by"
style="
--display: grid;
--gap: 1px;
"
>
<legend
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: var(--font-weight-eyebrow);
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Sort by
</legend>
<option value="recent" selected>Most recent</option>
<option value="popular">Most popular</option>
<option value="price-asc">Price: low to high</option>
<option value="price-desc">Price: high to low</option>
</optgroup>
</select>
</div>/**
* select.css: Select (:where([data-ui~="select"]) on customizable <select>)
*
* @layer variables, reset, components
* @requires _layers.css, _variables.css, fields.css, _reset.css, popover.css (--ui-popover-*), button.css (--ui-button-*)
* @uses appearance: base-select: customizable select
* @uses ::picker(select), ::picker-icon, selectedcontent, option::checkmark
* @uses @starting-style + allow-discrete: picker open animation
* @uses anchor-size(width): match picker width to trigger
* @uses position-area, position-try-fallbacks: placement tokens come from
* popover.css's shared matrix (@supports gated)
* @uses Degrades to native OS dropdown where base-select is unsupported
* @uses ui-multiselect (multiselect.ts): JS-projected dropdown for
* <select multiple> (base-select has no stable multi-select);
* degrades to the native multi listbox without JS
* @tokens --ui-select-* (@layer variables); picker panel reuses --ui-popover-* (owned in popover.css)
* @consumedby autocomplete.css, combobox.css, command.css (--ui-option-*, --ui-select-picker-icon-*)
*/
@layer variables {
:root {
/* Box treatment aliases the shared --ui-field-* family; remap a --ui-select-*
token to diverge just the select. */
--ui-select-w: 100%;
--ui-select-min-w: 16ch;
--ui-select-pl: var(--ui-field-px);
/* room for the picker icon */
--ui-select-pr: calc(var(--spacing) * 2);
--ui-select-font-size: var(--ui-field-font-size);
--ui-select-leading: var(--ui-field-leading);
--ui-select-border-width: var(--ui-field-border-width);
--ui-select-border-style: var(--ui-field-border-style);
--ui-select-border-color: var(--ui-field-border-color);
--ui-select-rounded: var(--ui-field-rounded);
--ui-select-option-gap: var(--ui-field-option-gap);
/* Picker icon (trigger chevron) */
--ui-select-picker-icon-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%3Cpolyline points='80 176 128 224 176 176' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3Cpolyline points='80 80 128 32 176 80' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E");
--ui-select-picker-icon-size: var(--ui-field-icon-size);
--ui-select-picker-icon-color: currentColor;
/* Option (menu item): a ghost button rendered as a native <option>. Aliases
--ui-button-*; only --ui-option-bg/--ui-option-border-color diverge
(no fill or border). Radius bridges to --ui-menu-button-rounded. */
--ui-option-font-family: var(--ui-button-font-family);
--ui-option-font-size: var(--ui-button-font-size);
--ui-option-font-weight: var(--ui-button-font-weight);
--ui-option-leading: var(--ui-button-leading);
--ui-option-gap: var(--ui-button-gap);
--ui-option-px: var(--ui-button-px);
--ui-option-min-h: var(--ui-button-min-h);
--ui-option-icon-size: var(--ui-button-icon-size);
--ui-option-rounded: var(--ui-menu-button-rounded);
--ui-option-shadow: none;
--ui-option-bg: transparent;
--ui-option-bg--hover: var(--ui-button-bg--hover);
--ui-option-bg--active: var(--ui-button-bg--active);
--ui-option-text: var(--ui-button-text);
--ui-option-text--hover: var(--ui-button-text--hover);
--ui-option-text--active: var(--ui-button-text--active);
--ui-option-border-width: var(--ui-button-border-width);
--ui-option-border-style: var(--ui-button-border-style);
--ui-option-border-color: transparent;
--ui-option-border-color--hover: transparent;
--ui-option-border-color--active: transparent;
/* Option checkmark */
--ui-option-checkmark-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%3Cpolyline points='40 144 96 200 224 72' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E");
--ui-option-checkmark-size: var(--ui-field-icon-size);
--ui-option-checkmark-color: currentColor;
}
}
@layer reset {
/* ===========================================================================
SELECT: native <select> internals (::picker, <option>, ::picker-icon)
- In the reset layer so component overrides win.
- Both the select and ::picker(select) need appearance: base-select before
picker pseudo styles apply.
=========================================================================== */
:where([data-ui~="select"]),
:where([data-ui~="select"])::picker(select) {
appearance: base-select;
}
:where([data-ui~="select"])::picker(select) {
transform-origin: var(--ui-popover-origin);
opacity: var(--ui-popover-opacity--start);
transform: scale(var(--ui-popover-scale--start)) translate3d(var(--ui-popover-translate--start));
transition:
opacity var(--default-transition-duration) var(--default-transition-timing-function),
transform var(--default-transition-duration) var(--default-transition-timing-function),
padding var(--default-transition-duration) var(--default-transition-timing-function),
block-size var(--default-transition-duration) var(--default-transition-timing-function),
inline-size var(--default-transition-duration) var(--default-transition-timing-function),
overlay var(--default-transition-duration) var(--default-transition-timing-function)
allow-discrete,
display var(--default-transition-duration) var(--default-transition-timing-function)
allow-discrete;
}
:where([data-ui~="select"]:open)::picker(select) {
opacity: var(--ui-popover-opacity--end);
transform: scale(var(--ui-popover-scale--end)) translate3d(var(--ui-popover-translate--end));
display: flex;
}
@starting-style {
:where([data-ui~="select"]:open)::picker(select) {
opacity: var(--ui-popover-opacity--start);
transform: scale(var(--ui-popover-scale--start))
translate3d(var(--ui-popover-translate--start));
}
}
:where(option)::checkmark {
content: "";
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([data-ui~="select"])::picker-icon {
content: "";
inline-size: var(--ui-select-picker-icon-size);
block-size: var(--ui-select-picker-icon-size);
background-color: var(--ui-select-picker-icon-color);
mask: var(--ui-select-picker-icon-mask) center / contain no-repeat;
}
}
@layer ui.components {
/* ===========================================================================
SELECT: :where([data-ui~="select"]) (customizable <select>, no JS)
- Needs a <button><selectedcontent></selectedcontent></button> child. Degrades
to the native OS dropdown where unsupported (the _reset.css chevron covers it).
=========================================================================== */
:where([data-ui~="select"]) {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ui-select-option-gap);
--_w-len: var(--_w-resolved, var(--ui-select-w));
--_w-num: var(--_w-resolved, var(--ui-select-w));
inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
--_min-w-len: var(--_min-w-resolved, var(--ui-select-min-w));
--_min-w-num: var(--_min-w-resolved, var(--ui-select-min-w));
min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
--_h-len: var(--_h-resolved, var(--ui-field-h));
--_h-num: var(--_h-resolved, var(--ui-field-h));
block-size: calc(var(--_h-len) + var(--_h-num) * var(--spacing));
--_pl-len: var(--_pl-resolved, var(--ui-select-pl));
--_pl-num: var(--_pl-resolved, var(--ui-select-pl));
padding-inline-start: calc(var(--_pl-len) + var(--_pl-num) * var(--spacing));
--_pr-len: var(--_pr-resolved, var(--ui-select-pr));
--_pr-num: var(--_pr-resolved, var(--ui-select-pr));
padding-inline-end: calc(var(--_pr-len) + var(--_pr-num) * var(--spacing));
font-size: var(--_font-size-resolved, var(--ui-select-font-size));
line-height: var(--_leading-resolved, var(--ui-select-leading));
text-align: start;
color: var(--_text-resolved, var(--ui-field-text));
background-color: var(--_bg-resolved, var(--ui-field-bg));
background-image: none;
border-width: var(--_border-width-resolved, var(--ui-select-border-width));
border-style: var(--_border-style-resolved, var(--ui-select-border-style));
border-color: var(--_border-color-resolved, var(--ui-select-border-color));
border-radius: var(--_rounded-resolved, var(--ui-select-rounded));
cursor: pointer;
/* the transparent outline keeps focus visible in forced-colors */
--_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);
--_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([data-ui~="select"]):hover {
border-color: var(--_border-color-resolved, var(--ui-field-border-color--hover));
}
}
/* :is() takes a forgiving list: an engine without :open keeps the focus ring */
:where([data-ui~="select"]):is(:focus-visible, :open) {
border-color: var(--_border-color-resolved, var(--ui-field-border-color--focus-within));
--_ring-offset-width: var(--ring-offset-width);
--_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
outline-color: transparent;
}
:where([data-ui~="select"]) > button {
block-size: 100%;
display: flex;
flex: 1;
align-items: center;
gap: var(--ui-field-gap);
min-inline-size: 0;
overflow: hidden;
}
:where([data-ui~="select"]) selectedcontent {
display: flex;
align-items: center;
block-size: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:where([data-ui~="select"]) > button > svg,
:where([data-ui~="select"]) selectedcontent > svg {
flex-shrink: 0;
inline-size: var(--ui-field-icon-size);
block-size: var(--ui-field-icon-size);
color: var(--color-muted-foreground);
}
:where([data-ui~="select"]):open {
background-color: var(--_bg-resolved, var(--ui-field-bg--focus-within));
}
:where([data-ui~="select"]):open::picker-icon {
transform: rotate(-180deg);
}
/* Where anchor-size() is missing the declaration is dropped, so the panel
keeps max-content sizing. */
:where([data-ui~="select"])::picker(select) {
flex-direction: column;
gap: 1px;
inline-size: max-content;
max-inline-size: var(--ui-popover-max-w);
padding: var(--ui-popover-p);
margin-block: var(--ui-popover-my);
overflow: clip;
background-color: var(--color-popover);
color: var(--color-popover-foreground);
border: 1px solid var(--ui-popover-border-color);
border-radius: var(--ui-popover-rounded);
box-shadow: var(--ui-popover-shadow);
}
:where([data-ui~="select"])::picker(select) {
min-inline-size: anchor-size(width);
}
/* ===========================================================================
PICKER PLACEMENT: the placement tokens come from the matrix below (select
custom properties inherit into ::picker(select)); applying them is gated on
anchor support.
=========================================================================== */
/* The picker is implicitly anchored, so no anchor-name is needed; gated like
popover.css so unsupporting browsers keep the UA placement. */
@supports (anchor-name: --zazz) {
:where([data-ui~="select"])::picker(select) {
position-area: var(--ui-popover-position-area);
position-try-fallbacks: var(--ui-popover-position-try-fallbacks);
margin-inline: var(--ui-popover-mx);
}
}
/* Like [data-ui~="button"], sizing rides on min-block-size + padding-inline
(no block padding) so every row is uniform. */
:where([data-ui~="select"]) option {
display: flex;
align-items: center;
gap: var(--ui-option-gap);
min-block-size: var(--ui-option-min-h);
padding-inline: var(--ui-option-px);
font-family: var(--ui-option-font-family);
font-size: var(--ui-option-font-size);
font-weight: var(--ui-option-font-weight);
line-height: var(--ui-option-leading);
color: var(--ui-option-text);
background-color: var(--ui-option-bg);
border: var(--ui-option-border-width) var(--ui-option-border-style)
var(--ui-option-border-color);
border-radius: var(--ui-option-rounded);
box-shadow: var(--ui-option-shadow);
cursor: pointer;
transition: var(--default-transition);
}
@media (hover: hover) {
:where([data-ui~="select"]) option:hover {
color: var(--ui-option-text--hover);
background-color: var(--ui-option-bg--hover);
border-color: var(--ui-option-border-color--hover);
}
}
:where([data-ui~="select"]) option:checked {
color: var(--ui-option-text--active);
background-color: var(--ui-option-bg--active);
border-color: var(--ui-option-border-color--active);
}
:where([data-ui~="select"]) option:disabled {
--ui-option-text: var(--color-muted-foreground);
cursor: default;
opacity: 0.5;
}
:where([data-ui~="select"]) option > svg {
flex-shrink: 0;
inline-size: var(--ui-option-icon-size);
block-size: var(--ui-option-icon-size);
color: var(--color-muted-foreground);
}
:where([data-ui~="select"]) option::checkmark {
order: 1;
margin-inline-start: auto;
color: currentcolor;
}
/* ===========================================================================
MULTISELECT: ui-multiselect (JS-projected dropdown over <select multiple>)
- The script hides the real select and stamps a trigger plus a popover of
checkbox rows (see multiselect.ts).
- Without JS the native multi listbox renders with minimal field styling.
=========================================================================== */
:where(ui-multiselect, [data-ui~="multiselect"]) {
anchor-scope: --ui-multiselect-trigger;
display: block;
--_w-len: var(--_w-resolved, var(--ui-select-w));
--_w-num: var(--_w-resolved, var(--ui-select-w));
inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
--_min-w-len: var(--_min-w-resolved, var(--ui-select-min-w));
--_min-w-num: var(--_min-w-resolved, var(--ui-select-min-w));
min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
}
/* The real select stays in the DOM as the form value carrier */
:where(ui-multiselect, [data-ui~="multiselect"])
select[multiple][data-multiselect-state~="enhanced"] {
display: none;
}
:where(ui-multiselect, [data-ui~="multiselect"])
select[multiple][data-ui~="select"]:not([data-multiselect-state~="enhanced"]) {
display: block;
block-size: auto;
padding-block: var(--spacing);
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="trigger"] {
anchor-name: --ui-multiselect-trigger;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="label"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="icon"] {
flex-shrink: 0;
inline-size: var(--ui-select-picker-icon-size);
block-size: var(--ui-select-picker-icon-size);
background-color: var(--ui-select-picker-icon-color);
mask: var(--ui-select-picker-icon-mask) center / contain no-repeat;
transition: var(--default-transition);
}
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="trigger"][aria-expanded="true"]
[data-multiselect-slot~="icon"] {
transform: rotate(-180deg);
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="panel"] {
position-anchor: --ui-multiselect-trigger;
/* layout stays ungated so the close fade keeps it */
flex-direction: column;
gap: 1px;
box-shadow: var(--ui-menu-shadow);
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="panel"]:popover-open {
display: flex;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="panel"] {
min-inline-size: anchor-size(width);
}
/* The checkbox is real but visually replaced by the shared checkmark glyph */
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"] {
display: flex;
align-items: center;
gap: var(--ui-option-gap);
min-block-size: var(--ui-option-min-h);
padding-inline: var(--ui-option-px);
font-family: var(--ui-option-font-family);
font-size: var(--ui-option-font-size);
font-weight: var(--ui-option-font-weight);
line-height: var(--ui-option-leading);
color: var(--ui-option-text);
background-color: var(--ui-option-bg);
border: var(--ui-option-border-width) var(--ui-option-border-style)
var(--ui-option-border-color);
border-radius: var(--ui-option-rounded);
cursor: pointer;
transition: var(--default-transition);
}
@media (hover: hover) {
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"]:hover {
color: var(--ui-option-text--hover);
background-color: var(--ui-option-bg--hover);
}
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"]:has(:checked) {
color: var(--ui-option-text--active);
background-color: var(--ui-option-bg--active);
}
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="option"]:has(:disabled) {
color: var(--color-muted-foreground);
cursor: default;
opacity: 0.5;
}
/* The checkbox stays focusable and announced but invisible; keyboard focus
surfaces on the row via :focus-within */
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="option"]
input[type="checkbox"] {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: clip;
clip-path: inset(50%);
white-space: nowrap;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"]:focus-within {
background-color: var(--ui-option-bg--hover);
outline: var(--outline-width) var(--outline-style) var(--ring-shadow-color);
outline-offset: calc(var(--outline-offset) * -1);
}
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="option"]:has(:checked)::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;
}
}
/* An invalid control remaps the --ui-field-* family on itself. */
@layer ui.components {
:where([data-ui~="select"]):disabled {
opacity: 0.5;
cursor: not-allowed;
}
:where([data-ui~="select"]):user-invalid {
--ui-field-bg: color-mix(in oklch, var(--color-destructive) 5%, var(--color-input));
--ui-field-bg--hover: color-mix(in oklch, var(--color-destructive) 10%, var(--color-input));
--ui-field-border-color: var(--color-destructive);
--ui-field-border-color--hover: var(--color-destructive);
--ui-field-border-color--focus-within: var(--color-destructive);
--ui-field-ring-color: var(--color-destructive);
border-color: var(--color-destructive);
}
}
/* Placement rows of the popover matrix for the native select, keyed on
data-select-side / data-select-align. */
@layer ui.components {
:where([data-ui~="select"])[data-select-side="bottom"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="bottom"][data-select-align="start"],
:where([data-ui~="select"]:not([data-select-side]):not([data-select-align])),
:where([data-ui~="select"]:not([data-select-side])[data-select-align="start"]) {
--ui-popover-position-area: block-end span-inline-end;
--ui-popover-origin: top center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="bottom"][data-select-align="center"],
:where([data-ui~="select"]:not([data-select-side])[data-select-align="center"]) {
--ui-popover-position-area: block-end span-all;
--ui-popover-origin: top center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="bottom"][data-select-align="end"],
:where([data-ui~="select"]:not([data-select-side])[data-select-align="end"]) {
--ui-popover-position-area: block-end span-inline-start;
--ui-popover-origin: top center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="top"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="top"][data-select-align="start"] {
--ui-popover-position-area: block-start span-inline-end;
--ui-popover-origin: bottom center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="top"][data-select-align="center"] {
--ui-popover-position-area: block-start span-all;
--ui-popover-origin: bottom center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="top"][data-select-align="end"] {
--ui-popover-position-area: block-start span-inline-start;
--ui-popover-origin: bottom center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="right"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="right"][data-select-align="start"] {
--ui-popover-position-area: inline-end span-block-end;
--ui-popover-origin: bottom left;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="right"][data-select-align="center"] {
--ui-popover-position-area: inline-end span-all;
--ui-popover-origin: center left;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="right"][data-select-align="end"] {
--ui-popover-position-area: inline-end span-block-start;
--ui-popover-origin: top left;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="left"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="left"][data-select-align="start"] {
--ui-popover-position-area: inline-start span-block-end;
--ui-popover-origin: bottom right;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="left"][data-select-align="center"] {
--ui-popover-position-area: inline-start span-all;
--ui-popover-origin: center right;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="left"][data-select-align="end"] {
--ui-popover-position-area: inline-start span-block-start;
--ui-popover-origin: top right;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
}// 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 };Align
<div
style="
--display: flex;
--items: start;
--gap: 4;
"
>
<div data-ui="field">
<label data-field-slot="label" for="f-align-start">Start</label>
<select data-ui="select" id="f-align-start" name="align-start" data-select-align="start">
<button>
<selectedcontent></selectedcontent>
</button>
<optgroup
label="Sort by"
style="
--display: grid;
--gap: 1px;
"
>
<legend
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: var(--font-weight-eyebrow);
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Sort by
</legend>
<option value="recent" selected>Most recent</option>
<option value="popular">Most popular</option>
<option value="price-asc">Price: low to high</option>
<option value="price-desc">Price: high to low</option>
</optgroup>
</select>
</div>
<div data-ui="field">
<label data-field-slot="label" for="f-align-center">Center</label>
<select data-ui="select" id="f-align-center" name="align-center" data-select-align="center">
<button>
<selectedcontent></selectedcontent>
</button>
<optgroup
label="Sort by"
style="
--display: grid;
--gap: 1px;
"
>
<legend
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: var(--font-weight-eyebrow);
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Sort by
</legend>
<option value="recent" selected>Most recent</option>
<option value="popular">Most popular</option>
<option value="price-asc">Price: low to high</option>
<option value="price-desc">Price: high to low</option>
</optgroup>
</select>
</div>
<div data-ui="field">
<label data-field-slot="label" for="f-align-end">End</label>
<select data-ui="select" id="f-align-end" name="align-end" data-select-align="end">
<button>
<selectedcontent></selectedcontent>
</button>
<optgroup
label="Sort by"
style="
--display: grid;
--gap: 1px;
"
>
<legend
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: var(--font-weight-eyebrow);
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Sort by
</legend>
<option value="recent" selected>Most recent</option>
<option value="popular">Most popular</option>
<option value="price-asc">Price: low to high</option>
<option value="price-desc">Price: high to low</option>
</optgroup>
</select>
</div>
</div>/**
* select.css: Select (:where([data-ui~="select"]) on customizable <select>)
*
* @layer variables, reset, components
* @requires _layers.css, _variables.css, fields.css, _reset.css, popover.css (--ui-popover-*), button.css (--ui-button-*)
* @uses appearance: base-select: customizable select
* @uses ::picker(select), ::picker-icon, selectedcontent, option::checkmark
* @uses @starting-style + allow-discrete: picker open animation
* @uses anchor-size(width): match picker width to trigger
* @uses position-area, position-try-fallbacks: placement tokens come from
* popover.css's shared matrix (@supports gated)
* @uses Degrades to native OS dropdown where base-select is unsupported
* @uses ui-multiselect (multiselect.ts): JS-projected dropdown for
* <select multiple> (base-select has no stable multi-select);
* degrades to the native multi listbox without JS
* @tokens --ui-select-* (@layer variables); picker panel reuses --ui-popover-* (owned in popover.css)
* @consumedby autocomplete.css, combobox.css, command.css (--ui-option-*, --ui-select-picker-icon-*)
*/
@layer variables {
:root {
/* Box treatment aliases the shared --ui-field-* family; remap a --ui-select-*
token to diverge just the select. */
--ui-select-w: 100%;
--ui-select-min-w: 16ch;
--ui-select-pl: var(--ui-field-px);
/* room for the picker icon */
--ui-select-pr: calc(var(--spacing) * 2);
--ui-select-font-size: var(--ui-field-font-size);
--ui-select-leading: var(--ui-field-leading);
--ui-select-border-width: var(--ui-field-border-width);
--ui-select-border-style: var(--ui-field-border-style);
--ui-select-border-color: var(--ui-field-border-color);
--ui-select-rounded: var(--ui-field-rounded);
--ui-select-option-gap: var(--ui-field-option-gap);
/* Picker icon (trigger chevron) */
--ui-select-picker-icon-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%3Cpolyline points='80 176 128 224 176 176' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3Cpolyline points='80 80 128 32 176 80' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E");
--ui-select-picker-icon-size: var(--ui-field-icon-size);
--ui-select-picker-icon-color: currentColor;
/* Option (menu item): a ghost button rendered as a native <option>. Aliases
--ui-button-*; only --ui-option-bg/--ui-option-border-color diverge
(no fill or border). Radius bridges to --ui-menu-button-rounded. */
--ui-option-font-family: var(--ui-button-font-family);
--ui-option-font-size: var(--ui-button-font-size);
--ui-option-font-weight: var(--ui-button-font-weight);
--ui-option-leading: var(--ui-button-leading);
--ui-option-gap: var(--ui-button-gap);
--ui-option-px: var(--ui-button-px);
--ui-option-min-h: var(--ui-button-min-h);
--ui-option-icon-size: var(--ui-button-icon-size);
--ui-option-rounded: var(--ui-menu-button-rounded);
--ui-option-shadow: none;
--ui-option-bg: transparent;
--ui-option-bg--hover: var(--ui-button-bg--hover);
--ui-option-bg--active: var(--ui-button-bg--active);
--ui-option-text: var(--ui-button-text);
--ui-option-text--hover: var(--ui-button-text--hover);
--ui-option-text--active: var(--ui-button-text--active);
--ui-option-border-width: var(--ui-button-border-width);
--ui-option-border-style: var(--ui-button-border-style);
--ui-option-border-color: transparent;
--ui-option-border-color--hover: transparent;
--ui-option-border-color--active: transparent;
/* Option checkmark */
--ui-option-checkmark-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%3Cpolyline points='40 144 96 200 224 72' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E");
--ui-option-checkmark-size: var(--ui-field-icon-size);
--ui-option-checkmark-color: currentColor;
}
}
@layer reset {
/* ===========================================================================
SELECT: native <select> internals (::picker, <option>, ::picker-icon)
- In the reset layer so component overrides win.
- Both the select and ::picker(select) need appearance: base-select before
picker pseudo styles apply.
=========================================================================== */
:where([data-ui~="select"]),
:where([data-ui~="select"])::picker(select) {
appearance: base-select;
}
:where([data-ui~="select"])::picker(select) {
transform-origin: var(--ui-popover-origin);
opacity: var(--ui-popover-opacity--start);
transform: scale(var(--ui-popover-scale--start)) translate3d(var(--ui-popover-translate--start));
transition:
opacity var(--default-transition-duration) var(--default-transition-timing-function),
transform var(--default-transition-duration) var(--default-transition-timing-function),
padding var(--default-transition-duration) var(--default-transition-timing-function),
block-size var(--default-transition-duration) var(--default-transition-timing-function),
inline-size var(--default-transition-duration) var(--default-transition-timing-function),
overlay var(--default-transition-duration) var(--default-transition-timing-function)
allow-discrete,
display var(--default-transition-duration) var(--default-transition-timing-function)
allow-discrete;
}
:where([data-ui~="select"]:open)::picker(select) {
opacity: var(--ui-popover-opacity--end);
transform: scale(var(--ui-popover-scale--end)) translate3d(var(--ui-popover-translate--end));
display: flex;
}
@starting-style {
:where([data-ui~="select"]:open)::picker(select) {
opacity: var(--ui-popover-opacity--start);
transform: scale(var(--ui-popover-scale--start))
translate3d(var(--ui-popover-translate--start));
}
}
:where(option)::checkmark {
content: "";
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([data-ui~="select"])::picker-icon {
content: "";
inline-size: var(--ui-select-picker-icon-size);
block-size: var(--ui-select-picker-icon-size);
background-color: var(--ui-select-picker-icon-color);
mask: var(--ui-select-picker-icon-mask) center / contain no-repeat;
}
}
@layer ui.components {
/* ===========================================================================
SELECT: :where([data-ui~="select"]) (customizable <select>, no JS)
- Needs a <button><selectedcontent></selectedcontent></button> child. Degrades
to the native OS dropdown where unsupported (the _reset.css chevron covers it).
=========================================================================== */
:where([data-ui~="select"]) {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ui-select-option-gap);
--_w-len: var(--_w-resolved, var(--ui-select-w));
--_w-num: var(--_w-resolved, var(--ui-select-w));
inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
--_min-w-len: var(--_min-w-resolved, var(--ui-select-min-w));
--_min-w-num: var(--_min-w-resolved, var(--ui-select-min-w));
min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
--_h-len: var(--_h-resolved, var(--ui-field-h));
--_h-num: var(--_h-resolved, var(--ui-field-h));
block-size: calc(var(--_h-len) + var(--_h-num) * var(--spacing));
--_pl-len: var(--_pl-resolved, var(--ui-select-pl));
--_pl-num: var(--_pl-resolved, var(--ui-select-pl));
padding-inline-start: calc(var(--_pl-len) + var(--_pl-num) * var(--spacing));
--_pr-len: var(--_pr-resolved, var(--ui-select-pr));
--_pr-num: var(--_pr-resolved, var(--ui-select-pr));
padding-inline-end: calc(var(--_pr-len) + var(--_pr-num) * var(--spacing));
font-size: var(--_font-size-resolved, var(--ui-select-font-size));
line-height: var(--_leading-resolved, var(--ui-select-leading));
text-align: start;
color: var(--_text-resolved, var(--ui-field-text));
background-color: var(--_bg-resolved, var(--ui-field-bg));
background-image: none;
border-width: var(--_border-width-resolved, var(--ui-select-border-width));
border-style: var(--_border-style-resolved, var(--ui-select-border-style));
border-color: var(--_border-color-resolved, var(--ui-select-border-color));
border-radius: var(--_rounded-resolved, var(--ui-select-rounded));
cursor: pointer;
/* the transparent outline keeps focus visible in forced-colors */
--_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);
--_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([data-ui~="select"]):hover {
border-color: var(--_border-color-resolved, var(--ui-field-border-color--hover));
}
}
/* :is() takes a forgiving list: an engine without :open keeps the focus ring */
:where([data-ui~="select"]):is(:focus-visible, :open) {
border-color: var(--_border-color-resolved, var(--ui-field-border-color--focus-within));
--_ring-offset-width: var(--ring-offset-width);
--_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
outline-color: transparent;
}
:where([data-ui~="select"]) > button {
block-size: 100%;
display: flex;
flex: 1;
align-items: center;
gap: var(--ui-field-gap);
min-inline-size: 0;
overflow: hidden;
}
:where([data-ui~="select"]) selectedcontent {
display: flex;
align-items: center;
block-size: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:where([data-ui~="select"]) > button > svg,
:where([data-ui~="select"]) selectedcontent > svg {
flex-shrink: 0;
inline-size: var(--ui-field-icon-size);
block-size: var(--ui-field-icon-size);
color: var(--color-muted-foreground);
}
:where([data-ui~="select"]):open {
background-color: var(--_bg-resolved, var(--ui-field-bg--focus-within));
}
:where([data-ui~="select"]):open::picker-icon {
transform: rotate(-180deg);
}
/* Where anchor-size() is missing the declaration is dropped, so the panel
keeps max-content sizing. */
:where([data-ui~="select"])::picker(select) {
flex-direction: column;
gap: 1px;
inline-size: max-content;
max-inline-size: var(--ui-popover-max-w);
padding: var(--ui-popover-p);
margin-block: var(--ui-popover-my);
overflow: clip;
background-color: var(--color-popover);
color: var(--color-popover-foreground);
border: 1px solid var(--ui-popover-border-color);
border-radius: var(--ui-popover-rounded);
box-shadow: var(--ui-popover-shadow);
}
:where([data-ui~="select"])::picker(select) {
min-inline-size: anchor-size(width);
}
/* ===========================================================================
PICKER PLACEMENT: the placement tokens come from the matrix below (select
custom properties inherit into ::picker(select)); applying them is gated on
anchor support.
=========================================================================== */
/* The picker is implicitly anchored, so no anchor-name is needed; gated like
popover.css so unsupporting browsers keep the UA placement. */
@supports (anchor-name: --zazz) {
:where([data-ui~="select"])::picker(select) {
position-area: var(--ui-popover-position-area);
position-try-fallbacks: var(--ui-popover-position-try-fallbacks);
margin-inline: var(--ui-popover-mx);
}
}
/* Like [data-ui~="button"], sizing rides on min-block-size + padding-inline
(no block padding) so every row is uniform. */
:where([data-ui~="select"]) option {
display: flex;
align-items: center;
gap: var(--ui-option-gap);
min-block-size: var(--ui-option-min-h);
padding-inline: var(--ui-option-px);
font-family: var(--ui-option-font-family);
font-size: var(--ui-option-font-size);
font-weight: var(--ui-option-font-weight);
line-height: var(--ui-option-leading);
color: var(--ui-option-text);
background-color: var(--ui-option-bg);
border: var(--ui-option-border-width) var(--ui-option-border-style)
var(--ui-option-border-color);
border-radius: var(--ui-option-rounded);
box-shadow: var(--ui-option-shadow);
cursor: pointer;
transition: var(--default-transition);
}
@media (hover: hover) {
:where([data-ui~="select"]) option:hover {
color: var(--ui-option-text--hover);
background-color: var(--ui-option-bg--hover);
border-color: var(--ui-option-border-color--hover);
}
}
:where([data-ui~="select"]) option:checked {
color: var(--ui-option-text--active);
background-color: var(--ui-option-bg--active);
border-color: var(--ui-option-border-color--active);
}
:where([data-ui~="select"]) option:disabled {
--ui-option-text: var(--color-muted-foreground);
cursor: default;
opacity: 0.5;
}
:where([data-ui~="select"]) option > svg {
flex-shrink: 0;
inline-size: var(--ui-option-icon-size);
block-size: var(--ui-option-icon-size);
color: var(--color-muted-foreground);
}
:where([data-ui~="select"]) option::checkmark {
order: 1;
margin-inline-start: auto;
color: currentcolor;
}
/* ===========================================================================
MULTISELECT: ui-multiselect (JS-projected dropdown over <select multiple>)
- The script hides the real select and stamps a trigger plus a popover of
checkbox rows (see multiselect.ts).
- Without JS the native multi listbox renders with minimal field styling.
=========================================================================== */
:where(ui-multiselect, [data-ui~="multiselect"]) {
anchor-scope: --ui-multiselect-trigger;
display: block;
--_w-len: var(--_w-resolved, var(--ui-select-w));
--_w-num: var(--_w-resolved, var(--ui-select-w));
inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
--_min-w-len: var(--_min-w-resolved, var(--ui-select-min-w));
--_min-w-num: var(--_min-w-resolved, var(--ui-select-min-w));
min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
}
/* The real select stays in the DOM as the form value carrier */
:where(ui-multiselect, [data-ui~="multiselect"])
select[multiple][data-multiselect-state~="enhanced"] {
display: none;
}
:where(ui-multiselect, [data-ui~="multiselect"])
select[multiple][data-ui~="select"]:not([data-multiselect-state~="enhanced"]) {
display: block;
block-size: auto;
padding-block: var(--spacing);
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="trigger"] {
anchor-name: --ui-multiselect-trigger;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="label"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="icon"] {
flex-shrink: 0;
inline-size: var(--ui-select-picker-icon-size);
block-size: var(--ui-select-picker-icon-size);
background-color: var(--ui-select-picker-icon-color);
mask: var(--ui-select-picker-icon-mask) center / contain no-repeat;
transition: var(--default-transition);
}
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="trigger"][aria-expanded="true"]
[data-multiselect-slot~="icon"] {
transform: rotate(-180deg);
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="panel"] {
position-anchor: --ui-multiselect-trigger;
/* layout stays ungated so the close fade keeps it */
flex-direction: column;
gap: 1px;
box-shadow: var(--ui-menu-shadow);
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="panel"]:popover-open {
display: flex;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="panel"] {
min-inline-size: anchor-size(width);
}
/* The checkbox is real but visually replaced by the shared checkmark glyph */
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"] {
display: flex;
align-items: center;
gap: var(--ui-option-gap);
min-block-size: var(--ui-option-min-h);
padding-inline: var(--ui-option-px);
font-family: var(--ui-option-font-family);
font-size: var(--ui-option-font-size);
font-weight: var(--ui-option-font-weight);
line-height: var(--ui-option-leading);
color: var(--ui-option-text);
background-color: var(--ui-option-bg);
border: var(--ui-option-border-width) var(--ui-option-border-style)
var(--ui-option-border-color);
border-radius: var(--ui-option-rounded);
cursor: pointer;
transition: var(--default-transition);
}
@media (hover: hover) {
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"]:hover {
color: var(--ui-option-text--hover);
background-color: var(--ui-option-bg--hover);
}
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"]:has(:checked) {
color: var(--ui-option-text--active);
background-color: var(--ui-option-bg--active);
}
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="option"]:has(:disabled) {
color: var(--color-muted-foreground);
cursor: default;
opacity: 0.5;
}
/* The checkbox stays focusable and announced but invisible; keyboard focus
surfaces on the row via :focus-within */
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="option"]
input[type="checkbox"] {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: clip;
clip-path: inset(50%);
white-space: nowrap;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"]:focus-within {
background-color: var(--ui-option-bg--hover);
outline: var(--outline-width) var(--outline-style) var(--ring-shadow-color);
outline-offset: calc(var(--outline-offset) * -1);
}
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="option"]:has(:checked)::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;
}
}
/* An invalid control remaps the --ui-field-* family on itself. */
@layer ui.components {
:where([data-ui~="select"]):disabled {
opacity: 0.5;
cursor: not-allowed;
}
:where([data-ui~="select"]):user-invalid {
--ui-field-bg: color-mix(in oklch, var(--color-destructive) 5%, var(--color-input));
--ui-field-bg--hover: color-mix(in oklch, var(--color-destructive) 10%, var(--color-input));
--ui-field-border-color: var(--color-destructive);
--ui-field-border-color--hover: var(--color-destructive);
--ui-field-border-color--focus-within: var(--color-destructive);
--ui-field-ring-color: var(--color-destructive);
border-color: var(--color-destructive);
}
}
/* Placement rows of the popover matrix for the native select, keyed on
data-select-side / data-select-align. */
@layer ui.components {
:where([data-ui~="select"])[data-select-side="bottom"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="bottom"][data-select-align="start"],
:where([data-ui~="select"]:not([data-select-side]):not([data-select-align])),
:where([data-ui~="select"]:not([data-select-side])[data-select-align="start"]) {
--ui-popover-position-area: block-end span-inline-end;
--ui-popover-origin: top center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="bottom"][data-select-align="center"],
:where([data-ui~="select"]:not([data-select-side])[data-select-align="center"]) {
--ui-popover-position-area: block-end span-all;
--ui-popover-origin: top center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="bottom"][data-select-align="end"],
:where([data-ui~="select"]:not([data-select-side])[data-select-align="end"]) {
--ui-popover-position-area: block-end span-inline-start;
--ui-popover-origin: top center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="top"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="top"][data-select-align="start"] {
--ui-popover-position-area: block-start span-inline-end;
--ui-popover-origin: bottom center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="top"][data-select-align="center"] {
--ui-popover-position-area: block-start span-all;
--ui-popover-origin: bottom center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="top"][data-select-align="end"] {
--ui-popover-position-area: block-start span-inline-start;
--ui-popover-origin: bottom center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="right"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="right"][data-select-align="start"] {
--ui-popover-position-area: inline-end span-block-end;
--ui-popover-origin: bottom left;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="right"][data-select-align="center"] {
--ui-popover-position-area: inline-end span-all;
--ui-popover-origin: center left;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="right"][data-select-align="end"] {
--ui-popover-position-area: inline-end span-block-start;
--ui-popover-origin: top left;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="left"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="left"][data-select-align="start"] {
--ui-popover-position-area: inline-start span-block-end;
--ui-popover-origin: bottom right;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="left"][data-select-align="center"] {
--ui-popover-position-area: inline-start span-all;
--ui-popover-origin: center right;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="left"][data-select-align="end"] {
--ui-popover-position-area: inline-start span-block-start;
--ui-popover-origin: top right;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
}// 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 };Sides
<div
style="
--display: grid;
--place-items: center;
--grid-cols: 2;
--gap: 4;
"
>
<div data-ui="field">
<label data-field-slot="label" for="f-side-top">Top</label>
<select data-ui="select" id="f-side-top" name="side-top" data-select-side="top">
<button>
<selectedcontent></selectedcontent>
</button>
<optgroup
label="Sort by"
style="
--display: grid;
--gap: 1px;
"
>
<legend
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: var(--font-weight-eyebrow);
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Sort by
</legend>
<option value="recent" selected>Most recent</option>
<option value="popular">Most popular</option>
<option value="price-asc">Price: low to high</option>
<option value="price-desc">Price: high to low</option>
</optgroup>
</select>
</div>
<div data-ui="field">
<label data-field-slot="label" for="f-side-bottom">Bottom</label>
<select data-ui="select" id="f-side-bottom" name="side-bottom" data-select-side="bottom">
<button>
<selectedcontent></selectedcontent>
</button>
<optgroup
label="Sort by"
style="
--display: grid;
--gap: 1px;
"
>
<legend
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: var(--font-weight-eyebrow);
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Sort by
</legend>
<option value="recent" selected>Most recent</option>
<option value="popular">Most popular</option>
<option value="price-asc">Price: low to high</option>
<option value="price-desc">Price: high to low</option>
</optgroup>
</select>
</div>
<div data-ui="field">
<label data-field-slot="label" for="f-side-left">Left</label>
<select data-ui="select" id="f-side-left" name="side-left" data-select-side="left">
<button>
<selectedcontent></selectedcontent>
</button>
<optgroup
label="Sort by"
style="
--display: grid;
--gap: 1px;
"
>
<legend
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: var(--font-weight-eyebrow);
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Sort by
</legend>
<option value="recent" selected>Most recent</option>
<option value="popular">Most popular</option>
<option value="price-asc">Price: low to high</option>
<option value="price-desc">Price: high to low</option>
</optgroup>
</select>
</div>
<div data-ui="field">
<label data-field-slot="label" for="f-side-right">Right</label>
<select data-ui="select" id="f-side-right" name="side-right" data-select-side="right">
<button>
<selectedcontent></selectedcontent>
</button>
<optgroup
label="Sort by"
style="
--display: grid;
--gap: 1px;
"
>
<legend
style="
--display: inline-flex;
--p: 2;
--font-size: var(--font-size-eyebrow);
--font-weight: var(--font-weight-eyebrow);
--leading: var(--leading-eyebrow);
--tracking: var(--tracking-eyebrow);
--text-transform: uppercase;
--text: var(--color-muted-foreground);
"
>
Sort by
</legend>
<option value="recent" selected>Most recent</option>
<option value="popular">Most popular</option>
<option value="price-asc">Price: low to high</option>
<option value="price-desc">Price: high to low</option>
</optgroup>
</select>
</div>
</div>/**
* select.css: Select (:where([data-ui~="select"]) on customizable <select>)
*
* @layer variables, reset, components
* @requires _layers.css, _variables.css, fields.css, _reset.css, popover.css (--ui-popover-*), button.css (--ui-button-*)
* @uses appearance: base-select: customizable select
* @uses ::picker(select), ::picker-icon, selectedcontent, option::checkmark
* @uses @starting-style + allow-discrete: picker open animation
* @uses anchor-size(width): match picker width to trigger
* @uses position-area, position-try-fallbacks: placement tokens come from
* popover.css's shared matrix (@supports gated)
* @uses Degrades to native OS dropdown where base-select is unsupported
* @uses ui-multiselect (multiselect.ts): JS-projected dropdown for
* <select multiple> (base-select has no stable multi-select);
* degrades to the native multi listbox without JS
* @tokens --ui-select-* (@layer variables); picker panel reuses --ui-popover-* (owned in popover.css)
* @consumedby autocomplete.css, combobox.css, command.css (--ui-option-*, --ui-select-picker-icon-*)
*/
@layer variables {
:root {
/* Box treatment aliases the shared --ui-field-* family; remap a --ui-select-*
token to diverge just the select. */
--ui-select-w: 100%;
--ui-select-min-w: 16ch;
--ui-select-pl: var(--ui-field-px);
/* room for the picker icon */
--ui-select-pr: calc(var(--spacing) * 2);
--ui-select-font-size: var(--ui-field-font-size);
--ui-select-leading: var(--ui-field-leading);
--ui-select-border-width: var(--ui-field-border-width);
--ui-select-border-style: var(--ui-field-border-style);
--ui-select-border-color: var(--ui-field-border-color);
--ui-select-rounded: var(--ui-field-rounded);
--ui-select-option-gap: var(--ui-field-option-gap);
/* Picker icon (trigger chevron) */
--ui-select-picker-icon-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%3Cpolyline points='80 176 128 224 176 176' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3Cpolyline points='80 80 128 32 176 80' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E");
--ui-select-picker-icon-size: var(--ui-field-icon-size);
--ui-select-picker-icon-color: currentColor;
/* Option (menu item): a ghost button rendered as a native <option>. Aliases
--ui-button-*; only --ui-option-bg/--ui-option-border-color diverge
(no fill or border). Radius bridges to --ui-menu-button-rounded. */
--ui-option-font-family: var(--ui-button-font-family);
--ui-option-font-size: var(--ui-button-font-size);
--ui-option-font-weight: var(--ui-button-font-weight);
--ui-option-leading: var(--ui-button-leading);
--ui-option-gap: var(--ui-button-gap);
--ui-option-px: var(--ui-button-px);
--ui-option-min-h: var(--ui-button-min-h);
--ui-option-icon-size: var(--ui-button-icon-size);
--ui-option-rounded: var(--ui-menu-button-rounded);
--ui-option-shadow: none;
--ui-option-bg: transparent;
--ui-option-bg--hover: var(--ui-button-bg--hover);
--ui-option-bg--active: var(--ui-button-bg--active);
--ui-option-text: var(--ui-button-text);
--ui-option-text--hover: var(--ui-button-text--hover);
--ui-option-text--active: var(--ui-button-text--active);
--ui-option-border-width: var(--ui-button-border-width);
--ui-option-border-style: var(--ui-button-border-style);
--ui-option-border-color: transparent;
--ui-option-border-color--hover: transparent;
--ui-option-border-color--active: transparent;
/* Option checkmark */
--ui-option-checkmark-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%3Cpolyline points='40 144 96 200 224 72' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E");
--ui-option-checkmark-size: var(--ui-field-icon-size);
--ui-option-checkmark-color: currentColor;
}
}
@layer reset {
/* ===========================================================================
SELECT: native <select> internals (::picker, <option>, ::picker-icon)
- In the reset layer so component overrides win.
- Both the select and ::picker(select) need appearance: base-select before
picker pseudo styles apply.
=========================================================================== */
:where([data-ui~="select"]),
:where([data-ui~="select"])::picker(select) {
appearance: base-select;
}
:where([data-ui~="select"])::picker(select) {
transform-origin: var(--ui-popover-origin);
opacity: var(--ui-popover-opacity--start);
transform: scale(var(--ui-popover-scale--start)) translate3d(var(--ui-popover-translate--start));
transition:
opacity var(--default-transition-duration) var(--default-transition-timing-function),
transform var(--default-transition-duration) var(--default-transition-timing-function),
padding var(--default-transition-duration) var(--default-transition-timing-function),
block-size var(--default-transition-duration) var(--default-transition-timing-function),
inline-size var(--default-transition-duration) var(--default-transition-timing-function),
overlay var(--default-transition-duration) var(--default-transition-timing-function)
allow-discrete,
display var(--default-transition-duration) var(--default-transition-timing-function)
allow-discrete;
}
:where([data-ui~="select"]:open)::picker(select) {
opacity: var(--ui-popover-opacity--end);
transform: scale(var(--ui-popover-scale--end)) translate3d(var(--ui-popover-translate--end));
display: flex;
}
@starting-style {
:where([data-ui~="select"]:open)::picker(select) {
opacity: var(--ui-popover-opacity--start);
transform: scale(var(--ui-popover-scale--start))
translate3d(var(--ui-popover-translate--start));
}
}
:where(option)::checkmark {
content: "";
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([data-ui~="select"])::picker-icon {
content: "";
inline-size: var(--ui-select-picker-icon-size);
block-size: var(--ui-select-picker-icon-size);
background-color: var(--ui-select-picker-icon-color);
mask: var(--ui-select-picker-icon-mask) center / contain no-repeat;
}
}
@layer ui.components {
/* ===========================================================================
SELECT: :where([data-ui~="select"]) (customizable <select>, no JS)
- Needs a <button><selectedcontent></selectedcontent></button> child. Degrades
to the native OS dropdown where unsupported (the _reset.css chevron covers it).
=========================================================================== */
:where([data-ui~="select"]) {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ui-select-option-gap);
--_w-len: var(--_w-resolved, var(--ui-select-w));
--_w-num: var(--_w-resolved, var(--ui-select-w));
inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
--_min-w-len: var(--_min-w-resolved, var(--ui-select-min-w));
--_min-w-num: var(--_min-w-resolved, var(--ui-select-min-w));
min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
--_h-len: var(--_h-resolved, var(--ui-field-h));
--_h-num: var(--_h-resolved, var(--ui-field-h));
block-size: calc(var(--_h-len) + var(--_h-num) * var(--spacing));
--_pl-len: var(--_pl-resolved, var(--ui-select-pl));
--_pl-num: var(--_pl-resolved, var(--ui-select-pl));
padding-inline-start: calc(var(--_pl-len) + var(--_pl-num) * var(--spacing));
--_pr-len: var(--_pr-resolved, var(--ui-select-pr));
--_pr-num: var(--_pr-resolved, var(--ui-select-pr));
padding-inline-end: calc(var(--_pr-len) + var(--_pr-num) * var(--spacing));
font-size: var(--_font-size-resolved, var(--ui-select-font-size));
line-height: var(--_leading-resolved, var(--ui-select-leading));
text-align: start;
color: var(--_text-resolved, var(--ui-field-text));
background-color: var(--_bg-resolved, var(--ui-field-bg));
background-image: none;
border-width: var(--_border-width-resolved, var(--ui-select-border-width));
border-style: var(--_border-style-resolved, var(--ui-select-border-style));
border-color: var(--_border-color-resolved, var(--ui-select-border-color));
border-radius: var(--_rounded-resolved, var(--ui-select-rounded));
cursor: pointer;
/* the transparent outline keeps focus visible in forced-colors */
--_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);
--_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([data-ui~="select"]):hover {
border-color: var(--_border-color-resolved, var(--ui-field-border-color--hover));
}
}
/* :is() takes a forgiving list: an engine without :open keeps the focus ring */
:where([data-ui~="select"]):is(:focus-visible, :open) {
border-color: var(--_border-color-resolved, var(--ui-field-border-color--focus-within));
--_ring-offset-width: var(--ring-offset-width);
--_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
outline-color: transparent;
}
:where([data-ui~="select"]) > button {
block-size: 100%;
display: flex;
flex: 1;
align-items: center;
gap: var(--ui-field-gap);
min-inline-size: 0;
overflow: hidden;
}
:where([data-ui~="select"]) selectedcontent {
display: flex;
align-items: center;
block-size: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:where([data-ui~="select"]) > button > svg,
:where([data-ui~="select"]) selectedcontent > svg {
flex-shrink: 0;
inline-size: var(--ui-field-icon-size);
block-size: var(--ui-field-icon-size);
color: var(--color-muted-foreground);
}
:where([data-ui~="select"]):open {
background-color: var(--_bg-resolved, var(--ui-field-bg--focus-within));
}
:where([data-ui~="select"]):open::picker-icon {
transform: rotate(-180deg);
}
/* Where anchor-size() is missing the declaration is dropped, so the panel
keeps max-content sizing. */
:where([data-ui~="select"])::picker(select) {
flex-direction: column;
gap: 1px;
inline-size: max-content;
max-inline-size: var(--ui-popover-max-w);
padding: var(--ui-popover-p);
margin-block: var(--ui-popover-my);
overflow: clip;
background-color: var(--color-popover);
color: var(--color-popover-foreground);
border: 1px solid var(--ui-popover-border-color);
border-radius: var(--ui-popover-rounded);
box-shadow: var(--ui-popover-shadow);
}
:where([data-ui~="select"])::picker(select) {
min-inline-size: anchor-size(width);
}
/* ===========================================================================
PICKER PLACEMENT: the placement tokens come from the matrix below (select
custom properties inherit into ::picker(select)); applying them is gated on
anchor support.
=========================================================================== */
/* The picker is implicitly anchored, so no anchor-name is needed; gated like
popover.css so unsupporting browsers keep the UA placement. */
@supports (anchor-name: --zazz) {
:where([data-ui~="select"])::picker(select) {
position-area: var(--ui-popover-position-area);
position-try-fallbacks: var(--ui-popover-position-try-fallbacks);
margin-inline: var(--ui-popover-mx);
}
}
/* Like [data-ui~="button"], sizing rides on min-block-size + padding-inline
(no block padding) so every row is uniform. */
:where([data-ui~="select"]) option {
display: flex;
align-items: center;
gap: var(--ui-option-gap);
min-block-size: var(--ui-option-min-h);
padding-inline: var(--ui-option-px);
font-family: var(--ui-option-font-family);
font-size: var(--ui-option-font-size);
font-weight: var(--ui-option-font-weight);
line-height: var(--ui-option-leading);
color: var(--ui-option-text);
background-color: var(--ui-option-bg);
border: var(--ui-option-border-width) var(--ui-option-border-style)
var(--ui-option-border-color);
border-radius: var(--ui-option-rounded);
box-shadow: var(--ui-option-shadow);
cursor: pointer;
transition: var(--default-transition);
}
@media (hover: hover) {
:where([data-ui~="select"]) option:hover {
color: var(--ui-option-text--hover);
background-color: var(--ui-option-bg--hover);
border-color: var(--ui-option-border-color--hover);
}
}
:where([data-ui~="select"]) option:checked {
color: var(--ui-option-text--active);
background-color: var(--ui-option-bg--active);
border-color: var(--ui-option-border-color--active);
}
:where([data-ui~="select"]) option:disabled {
--ui-option-text: var(--color-muted-foreground);
cursor: default;
opacity: 0.5;
}
:where([data-ui~="select"]) option > svg {
flex-shrink: 0;
inline-size: var(--ui-option-icon-size);
block-size: var(--ui-option-icon-size);
color: var(--color-muted-foreground);
}
:where([data-ui~="select"]) option::checkmark {
order: 1;
margin-inline-start: auto;
color: currentcolor;
}
/* ===========================================================================
MULTISELECT: ui-multiselect (JS-projected dropdown over <select multiple>)
- The script hides the real select and stamps a trigger plus a popover of
checkbox rows (see multiselect.ts).
- Without JS the native multi listbox renders with minimal field styling.
=========================================================================== */
:where(ui-multiselect, [data-ui~="multiselect"]) {
anchor-scope: --ui-multiselect-trigger;
display: block;
--_w-len: var(--_w-resolved, var(--ui-select-w));
--_w-num: var(--_w-resolved, var(--ui-select-w));
inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
--_min-w-len: var(--_min-w-resolved, var(--ui-select-min-w));
--_min-w-num: var(--_min-w-resolved, var(--ui-select-min-w));
min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
}
/* The real select stays in the DOM as the form value carrier */
:where(ui-multiselect, [data-ui~="multiselect"])
select[multiple][data-multiselect-state~="enhanced"] {
display: none;
}
:where(ui-multiselect, [data-ui~="multiselect"])
select[multiple][data-ui~="select"]:not([data-multiselect-state~="enhanced"]) {
display: block;
block-size: auto;
padding-block: var(--spacing);
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="trigger"] {
anchor-name: --ui-multiselect-trigger;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="label"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="icon"] {
flex-shrink: 0;
inline-size: var(--ui-select-picker-icon-size);
block-size: var(--ui-select-picker-icon-size);
background-color: var(--ui-select-picker-icon-color);
mask: var(--ui-select-picker-icon-mask) center / contain no-repeat;
transition: var(--default-transition);
}
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="trigger"][aria-expanded="true"]
[data-multiselect-slot~="icon"] {
transform: rotate(-180deg);
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="panel"] {
position-anchor: --ui-multiselect-trigger;
/* layout stays ungated so the close fade keeps it */
flex-direction: column;
gap: 1px;
box-shadow: var(--ui-menu-shadow);
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="panel"]:popover-open {
display: flex;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="panel"] {
min-inline-size: anchor-size(width);
}
/* The checkbox is real but visually replaced by the shared checkmark glyph */
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"] {
display: flex;
align-items: center;
gap: var(--ui-option-gap);
min-block-size: var(--ui-option-min-h);
padding-inline: var(--ui-option-px);
font-family: var(--ui-option-font-family);
font-size: var(--ui-option-font-size);
font-weight: var(--ui-option-font-weight);
line-height: var(--ui-option-leading);
color: var(--ui-option-text);
background-color: var(--ui-option-bg);
border: var(--ui-option-border-width) var(--ui-option-border-style)
var(--ui-option-border-color);
border-radius: var(--ui-option-rounded);
cursor: pointer;
transition: var(--default-transition);
}
@media (hover: hover) {
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"]:hover {
color: var(--ui-option-text--hover);
background-color: var(--ui-option-bg--hover);
}
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"]:has(:checked) {
color: var(--ui-option-text--active);
background-color: var(--ui-option-bg--active);
}
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="option"]:has(:disabled) {
color: var(--color-muted-foreground);
cursor: default;
opacity: 0.5;
}
/* The checkbox stays focusable and announced but invisible; keyboard focus
surfaces on the row via :focus-within */
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="option"]
input[type="checkbox"] {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: clip;
clip-path: inset(50%);
white-space: nowrap;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"]:focus-within {
background-color: var(--ui-option-bg--hover);
outline: var(--outline-width) var(--outline-style) var(--ring-shadow-color);
outline-offset: calc(var(--outline-offset) * -1);
}
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="option"]:has(:checked)::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;
}
}
/* An invalid control remaps the --ui-field-* family on itself. */
@layer ui.components {
:where([data-ui~="select"]):disabled {
opacity: 0.5;
cursor: not-allowed;
}
:where([data-ui~="select"]):user-invalid {
--ui-field-bg: color-mix(in oklch, var(--color-destructive) 5%, var(--color-input));
--ui-field-bg--hover: color-mix(in oklch, var(--color-destructive) 10%, var(--color-input));
--ui-field-border-color: var(--color-destructive);
--ui-field-border-color--hover: var(--color-destructive);
--ui-field-border-color--focus-within: var(--color-destructive);
--ui-field-ring-color: var(--color-destructive);
border-color: var(--color-destructive);
}
}
/* Placement rows of the popover matrix for the native select, keyed on
data-select-side / data-select-align. */
@layer ui.components {
:where([data-ui~="select"])[data-select-side="bottom"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="bottom"][data-select-align="start"],
:where([data-ui~="select"]:not([data-select-side]):not([data-select-align])),
:where([data-ui~="select"]:not([data-select-side])[data-select-align="start"]) {
--ui-popover-position-area: block-end span-inline-end;
--ui-popover-origin: top center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="bottom"][data-select-align="center"],
:where([data-ui~="select"]:not([data-select-side])[data-select-align="center"]) {
--ui-popover-position-area: block-end span-all;
--ui-popover-origin: top center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="bottom"][data-select-align="end"],
:where([data-ui~="select"]:not([data-select-side])[data-select-align="end"]) {
--ui-popover-position-area: block-end span-inline-start;
--ui-popover-origin: top center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="top"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="top"][data-select-align="start"] {
--ui-popover-position-area: block-start span-inline-end;
--ui-popover-origin: bottom center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="top"][data-select-align="center"] {
--ui-popover-position-area: block-start span-all;
--ui-popover-origin: bottom center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="top"][data-select-align="end"] {
--ui-popover-position-area: block-start span-inline-start;
--ui-popover-origin: bottom center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="right"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="right"][data-select-align="start"] {
--ui-popover-position-area: inline-end span-block-end;
--ui-popover-origin: bottom left;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="right"][data-select-align="center"] {
--ui-popover-position-area: inline-end span-all;
--ui-popover-origin: center left;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="right"][data-select-align="end"] {
--ui-popover-position-area: inline-end span-block-start;
--ui-popover-origin: top left;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="left"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="left"][data-select-align="start"] {
--ui-popover-position-area: inline-start span-block-end;
--ui-popover-origin: bottom right;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="left"][data-select-align="center"] {
--ui-popover-position-area: inline-start span-all;
--ui-popover-origin: center right;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="left"][data-select-align="end"] {
--ui-popover-position-area: inline-start span-block-start;
--ui-popover-origin: top right;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
}// 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 };Multiple
<div data-ui="field" style="--max-w: 96">
<label data-field-slot="label" for="f-tags">Tags</label>
<ui-multiselect
data-multiselect-placeholder="Select tags"
data-multiselect-side="bottom"
data-multiselect-align="start"
>
<select data-ui="select" id="f-tags" name="tags" multiple>
<option value="design" selected>Design</option>
<option value="engineering" selected>Engineering</option>
<option value="research">Research</option>
<option value="marketing">Marketing</option>
<option value="ops" disabled>Operations</option>
</select>
</ui-multiselect>
</div>/**
* select.css: Select (:where([data-ui~="select"]) on customizable <select>)
*
* @layer variables, reset, components
* @requires _layers.css, _variables.css, fields.css, _reset.css, popover.css (--ui-popover-*), button.css (--ui-button-*)
* @uses appearance: base-select: customizable select
* @uses ::picker(select), ::picker-icon, selectedcontent, option::checkmark
* @uses @starting-style + allow-discrete: picker open animation
* @uses anchor-size(width): match picker width to trigger
* @uses position-area, position-try-fallbacks: placement tokens come from
* popover.css's shared matrix (@supports gated)
* @uses Degrades to native OS dropdown where base-select is unsupported
* @uses ui-multiselect (multiselect.ts): JS-projected dropdown for
* <select multiple> (base-select has no stable multi-select);
* degrades to the native multi listbox without JS
* @tokens --ui-select-* (@layer variables); picker panel reuses --ui-popover-* (owned in popover.css)
* @consumedby autocomplete.css, combobox.css, command.css (--ui-option-*, --ui-select-picker-icon-*)
*/
@layer variables {
:root {
/* Box treatment aliases the shared --ui-field-* family; remap a --ui-select-*
token to diverge just the select. */
--ui-select-w: 100%;
--ui-select-min-w: 16ch;
--ui-select-pl: var(--ui-field-px);
/* room for the picker icon */
--ui-select-pr: calc(var(--spacing) * 2);
--ui-select-font-size: var(--ui-field-font-size);
--ui-select-leading: var(--ui-field-leading);
--ui-select-border-width: var(--ui-field-border-width);
--ui-select-border-style: var(--ui-field-border-style);
--ui-select-border-color: var(--ui-field-border-color);
--ui-select-rounded: var(--ui-field-rounded);
--ui-select-option-gap: var(--ui-field-option-gap);
/* Picker icon (trigger chevron) */
--ui-select-picker-icon-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%3Cpolyline points='80 176 128 224 176 176' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3Cpolyline points='80 80 128 32 176 80' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E");
--ui-select-picker-icon-size: var(--ui-field-icon-size);
--ui-select-picker-icon-color: currentColor;
/* Option (menu item): a ghost button rendered as a native <option>. Aliases
--ui-button-*; only --ui-option-bg/--ui-option-border-color diverge
(no fill or border). Radius bridges to --ui-menu-button-rounded. */
--ui-option-font-family: var(--ui-button-font-family);
--ui-option-font-size: var(--ui-button-font-size);
--ui-option-font-weight: var(--ui-button-font-weight);
--ui-option-leading: var(--ui-button-leading);
--ui-option-gap: var(--ui-button-gap);
--ui-option-px: var(--ui-button-px);
--ui-option-min-h: var(--ui-button-min-h);
--ui-option-icon-size: var(--ui-button-icon-size);
--ui-option-rounded: var(--ui-menu-button-rounded);
--ui-option-shadow: none;
--ui-option-bg: transparent;
--ui-option-bg--hover: var(--ui-button-bg--hover);
--ui-option-bg--active: var(--ui-button-bg--active);
--ui-option-text: var(--ui-button-text);
--ui-option-text--hover: var(--ui-button-text--hover);
--ui-option-text--active: var(--ui-button-text--active);
--ui-option-border-width: var(--ui-button-border-width);
--ui-option-border-style: var(--ui-button-border-style);
--ui-option-border-color: transparent;
--ui-option-border-color--hover: transparent;
--ui-option-border-color--active: transparent;
/* Option checkmark */
--ui-option-checkmark-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%3Cpolyline points='40 144 96 200 224 72' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E");
--ui-option-checkmark-size: var(--ui-field-icon-size);
--ui-option-checkmark-color: currentColor;
}
}
@layer reset {
/* ===========================================================================
SELECT: native <select> internals (::picker, <option>, ::picker-icon)
- In the reset layer so component overrides win.
- Both the select and ::picker(select) need appearance: base-select before
picker pseudo styles apply.
=========================================================================== */
:where([data-ui~="select"]),
:where([data-ui~="select"])::picker(select) {
appearance: base-select;
}
:where([data-ui~="select"])::picker(select) {
transform-origin: var(--ui-popover-origin);
opacity: var(--ui-popover-opacity--start);
transform: scale(var(--ui-popover-scale--start)) translate3d(var(--ui-popover-translate--start));
transition:
opacity var(--default-transition-duration) var(--default-transition-timing-function),
transform var(--default-transition-duration) var(--default-transition-timing-function),
padding var(--default-transition-duration) var(--default-transition-timing-function),
block-size var(--default-transition-duration) var(--default-transition-timing-function),
inline-size var(--default-transition-duration) var(--default-transition-timing-function),
overlay var(--default-transition-duration) var(--default-transition-timing-function)
allow-discrete,
display var(--default-transition-duration) var(--default-transition-timing-function)
allow-discrete;
}
:where([data-ui~="select"]:open)::picker(select) {
opacity: var(--ui-popover-opacity--end);
transform: scale(var(--ui-popover-scale--end)) translate3d(var(--ui-popover-translate--end));
display: flex;
}
@starting-style {
:where([data-ui~="select"]:open)::picker(select) {
opacity: var(--ui-popover-opacity--start);
transform: scale(var(--ui-popover-scale--start))
translate3d(var(--ui-popover-translate--start));
}
}
:where(option)::checkmark {
content: "";
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([data-ui~="select"])::picker-icon {
content: "";
inline-size: var(--ui-select-picker-icon-size);
block-size: var(--ui-select-picker-icon-size);
background-color: var(--ui-select-picker-icon-color);
mask: var(--ui-select-picker-icon-mask) center / contain no-repeat;
}
}
@layer ui.components {
/* ===========================================================================
SELECT: :where([data-ui~="select"]) (customizable <select>, no JS)
- Needs a <button><selectedcontent></selectedcontent></button> child. Degrades
to the native OS dropdown where unsupported (the _reset.css chevron covers it).
=========================================================================== */
:where([data-ui~="select"]) {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ui-select-option-gap);
--_w-len: var(--_w-resolved, var(--ui-select-w));
--_w-num: var(--_w-resolved, var(--ui-select-w));
inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
--_min-w-len: var(--_min-w-resolved, var(--ui-select-min-w));
--_min-w-num: var(--_min-w-resolved, var(--ui-select-min-w));
min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
--_h-len: var(--_h-resolved, var(--ui-field-h));
--_h-num: var(--_h-resolved, var(--ui-field-h));
block-size: calc(var(--_h-len) + var(--_h-num) * var(--spacing));
--_pl-len: var(--_pl-resolved, var(--ui-select-pl));
--_pl-num: var(--_pl-resolved, var(--ui-select-pl));
padding-inline-start: calc(var(--_pl-len) + var(--_pl-num) * var(--spacing));
--_pr-len: var(--_pr-resolved, var(--ui-select-pr));
--_pr-num: var(--_pr-resolved, var(--ui-select-pr));
padding-inline-end: calc(var(--_pr-len) + var(--_pr-num) * var(--spacing));
font-size: var(--_font-size-resolved, var(--ui-select-font-size));
line-height: var(--_leading-resolved, var(--ui-select-leading));
text-align: start;
color: var(--_text-resolved, var(--ui-field-text));
background-color: var(--_bg-resolved, var(--ui-field-bg));
background-image: none;
border-width: var(--_border-width-resolved, var(--ui-select-border-width));
border-style: var(--_border-style-resolved, var(--ui-select-border-style));
border-color: var(--_border-color-resolved, var(--ui-select-border-color));
border-radius: var(--_rounded-resolved, var(--ui-select-rounded));
cursor: pointer;
/* the transparent outline keeps focus visible in forced-colors */
--_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);
--_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([data-ui~="select"]):hover {
border-color: var(--_border-color-resolved, var(--ui-field-border-color--hover));
}
}
/* :is() takes a forgiving list: an engine without :open keeps the focus ring */
:where([data-ui~="select"]):is(:focus-visible, :open) {
border-color: var(--_border-color-resolved, var(--ui-field-border-color--focus-within));
--_ring-offset-width: var(--ring-offset-width);
--_ring-width: abs(var(--_focus-ring-w, var(--ring-width)));
outline-color: transparent;
}
:where([data-ui~="select"]) > button {
block-size: 100%;
display: flex;
flex: 1;
align-items: center;
gap: var(--ui-field-gap);
min-inline-size: 0;
overflow: hidden;
}
:where([data-ui~="select"]) selectedcontent {
display: flex;
align-items: center;
block-size: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:where([data-ui~="select"]) > button > svg,
:where([data-ui~="select"]) selectedcontent > svg {
flex-shrink: 0;
inline-size: var(--ui-field-icon-size);
block-size: var(--ui-field-icon-size);
color: var(--color-muted-foreground);
}
:where([data-ui~="select"]):open {
background-color: var(--_bg-resolved, var(--ui-field-bg--focus-within));
}
:where([data-ui~="select"]):open::picker-icon {
transform: rotate(-180deg);
}
/* Where anchor-size() is missing the declaration is dropped, so the panel
keeps max-content sizing. */
:where([data-ui~="select"])::picker(select) {
flex-direction: column;
gap: 1px;
inline-size: max-content;
max-inline-size: var(--ui-popover-max-w);
padding: var(--ui-popover-p);
margin-block: var(--ui-popover-my);
overflow: clip;
background-color: var(--color-popover);
color: var(--color-popover-foreground);
border: 1px solid var(--ui-popover-border-color);
border-radius: var(--ui-popover-rounded);
box-shadow: var(--ui-popover-shadow);
}
:where([data-ui~="select"])::picker(select) {
min-inline-size: anchor-size(width);
}
/* ===========================================================================
PICKER PLACEMENT: the placement tokens come from the matrix below (select
custom properties inherit into ::picker(select)); applying them is gated on
anchor support.
=========================================================================== */
/* The picker is implicitly anchored, so no anchor-name is needed; gated like
popover.css so unsupporting browsers keep the UA placement. */
@supports (anchor-name: --zazz) {
:where([data-ui~="select"])::picker(select) {
position-area: var(--ui-popover-position-area);
position-try-fallbacks: var(--ui-popover-position-try-fallbacks);
margin-inline: var(--ui-popover-mx);
}
}
/* Like [data-ui~="button"], sizing rides on min-block-size + padding-inline
(no block padding) so every row is uniform. */
:where([data-ui~="select"]) option {
display: flex;
align-items: center;
gap: var(--ui-option-gap);
min-block-size: var(--ui-option-min-h);
padding-inline: var(--ui-option-px);
font-family: var(--ui-option-font-family);
font-size: var(--ui-option-font-size);
font-weight: var(--ui-option-font-weight);
line-height: var(--ui-option-leading);
color: var(--ui-option-text);
background-color: var(--ui-option-bg);
border: var(--ui-option-border-width) var(--ui-option-border-style)
var(--ui-option-border-color);
border-radius: var(--ui-option-rounded);
box-shadow: var(--ui-option-shadow);
cursor: pointer;
transition: var(--default-transition);
}
@media (hover: hover) {
:where([data-ui~="select"]) option:hover {
color: var(--ui-option-text--hover);
background-color: var(--ui-option-bg--hover);
border-color: var(--ui-option-border-color--hover);
}
}
:where([data-ui~="select"]) option:checked {
color: var(--ui-option-text--active);
background-color: var(--ui-option-bg--active);
border-color: var(--ui-option-border-color--active);
}
:where([data-ui~="select"]) option:disabled {
--ui-option-text: var(--color-muted-foreground);
cursor: default;
opacity: 0.5;
}
:where([data-ui~="select"]) option > svg {
flex-shrink: 0;
inline-size: var(--ui-option-icon-size);
block-size: var(--ui-option-icon-size);
color: var(--color-muted-foreground);
}
:where([data-ui~="select"]) option::checkmark {
order: 1;
margin-inline-start: auto;
color: currentcolor;
}
/* ===========================================================================
MULTISELECT: ui-multiselect (JS-projected dropdown over <select multiple>)
- The script hides the real select and stamps a trigger plus a popover of
checkbox rows (see multiselect.ts).
- Without JS the native multi listbox renders with minimal field styling.
=========================================================================== */
:where(ui-multiselect, [data-ui~="multiselect"]) {
anchor-scope: --ui-multiselect-trigger;
display: block;
--_w-len: var(--_w-resolved, var(--ui-select-w));
--_w-num: var(--_w-resolved, var(--ui-select-w));
inline-size: calc(var(--_w-len) + var(--_w-num) * var(--spacing));
--_min-w-len: var(--_min-w-resolved, var(--ui-select-min-w));
--_min-w-num: var(--_min-w-resolved, var(--ui-select-min-w));
min-inline-size: calc(var(--_min-w-len) + var(--_min-w-num) * var(--spacing));
}
/* The real select stays in the DOM as the form value carrier */
:where(ui-multiselect, [data-ui~="multiselect"])
select[multiple][data-multiselect-state~="enhanced"] {
display: none;
}
:where(ui-multiselect, [data-ui~="multiselect"])
select[multiple][data-ui~="select"]:not([data-multiselect-state~="enhanced"]) {
display: block;
block-size: auto;
padding-block: var(--spacing);
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="trigger"] {
anchor-name: --ui-multiselect-trigger;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="label"] {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="icon"] {
flex-shrink: 0;
inline-size: var(--ui-select-picker-icon-size);
block-size: var(--ui-select-picker-icon-size);
background-color: var(--ui-select-picker-icon-color);
mask: var(--ui-select-picker-icon-mask) center / contain no-repeat;
transition: var(--default-transition);
}
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="trigger"][aria-expanded="true"]
[data-multiselect-slot~="icon"] {
transform: rotate(-180deg);
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="panel"] {
position-anchor: --ui-multiselect-trigger;
/* layout stays ungated so the close fade keeps it */
flex-direction: column;
gap: 1px;
box-shadow: var(--ui-menu-shadow);
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="panel"]:popover-open {
display: flex;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="panel"] {
min-inline-size: anchor-size(width);
}
/* The checkbox is real but visually replaced by the shared checkmark glyph */
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"] {
display: flex;
align-items: center;
gap: var(--ui-option-gap);
min-block-size: var(--ui-option-min-h);
padding-inline: var(--ui-option-px);
font-family: var(--ui-option-font-family);
font-size: var(--ui-option-font-size);
font-weight: var(--ui-option-font-weight);
line-height: var(--ui-option-leading);
color: var(--ui-option-text);
background-color: var(--ui-option-bg);
border: var(--ui-option-border-width) var(--ui-option-border-style)
var(--ui-option-border-color);
border-radius: var(--ui-option-rounded);
cursor: pointer;
transition: var(--default-transition);
}
@media (hover: hover) {
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"]:hover {
color: var(--ui-option-text--hover);
background-color: var(--ui-option-bg--hover);
}
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"]:has(:checked) {
color: var(--ui-option-text--active);
background-color: var(--ui-option-bg--active);
}
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="option"]:has(:disabled) {
color: var(--color-muted-foreground);
cursor: default;
opacity: 0.5;
}
/* The checkbox stays focusable and announced but invisible; keyboard focus
surfaces on the row via :focus-within */
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="option"]
input[type="checkbox"] {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: clip;
clip-path: inset(50%);
white-space: nowrap;
}
:where(ui-multiselect, [data-ui~="multiselect"]) [data-multiselect-slot~="option"]:focus-within {
background-color: var(--ui-option-bg--hover);
outline: var(--outline-width) var(--outline-style) var(--ring-shadow-color);
outline-offset: calc(var(--outline-offset) * -1);
}
:where(ui-multiselect, [data-ui~="multiselect"])
[data-multiselect-slot~="option"]:has(:checked)::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;
}
}
/* An invalid control remaps the --ui-field-* family on itself. */
@layer ui.components {
:where([data-ui~="select"]):disabled {
opacity: 0.5;
cursor: not-allowed;
}
:where([data-ui~="select"]):user-invalid {
--ui-field-bg: color-mix(in oklch, var(--color-destructive) 5%, var(--color-input));
--ui-field-bg--hover: color-mix(in oklch, var(--color-destructive) 10%, var(--color-input));
--ui-field-border-color: var(--color-destructive);
--ui-field-border-color--hover: var(--color-destructive);
--ui-field-border-color--focus-within: var(--color-destructive);
--ui-field-ring-color: var(--color-destructive);
border-color: var(--color-destructive);
}
}
/* Placement rows of the popover matrix for the native select, keyed on
data-select-side / data-select-align. */
@layer ui.components {
:where([data-ui~="select"])[data-select-side="bottom"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="bottom"][data-select-align="start"],
:where([data-ui~="select"]:not([data-select-side]):not([data-select-align])),
:where([data-ui~="select"]:not([data-select-side])[data-select-align="start"]) {
--ui-popover-position-area: block-end span-inline-end;
--ui-popover-origin: top center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="bottom"][data-select-align="center"],
:where([data-ui~="select"]:not([data-select-side])[data-select-align="center"]) {
--ui-popover-position-area: block-end span-all;
--ui-popover-origin: top center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="bottom"][data-select-align="end"],
:where([data-ui~="select"]:not([data-select-side])[data-select-align="end"]) {
--ui-popover-position-area: block-end span-inline-start;
--ui-popover-origin: top center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="top"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="top"][data-select-align="start"] {
--ui-popover-position-area: block-start span-inline-end;
--ui-popover-origin: bottom center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="top"][data-select-align="center"] {
--ui-popover-position-area: block-start span-all;
--ui-popover-origin: bottom center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="top"][data-select-align="end"] {
--ui-popover-position-area: block-start span-inline-start;
--ui-popover-origin: bottom center;
--ui-popover-my: calc(var(--spacing) * 1.5);
--ui-popover-mx: 0;
}
:where([data-ui~="select"])[data-select-side="right"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="right"][data-select-align="start"] {
--ui-popover-position-area: inline-end span-block-end;
--ui-popover-origin: bottom left;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="right"][data-select-align="center"] {
--ui-popover-position-area: inline-end span-all;
--ui-popover-origin: center left;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="right"][data-select-align="end"] {
--ui-popover-position-area: inline-end span-block-start;
--ui-popover-origin: top left;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="left"]:not([data-select-align]),
:where([data-ui~="select"])[data-select-side="left"][data-select-align="start"] {
--ui-popover-position-area: inline-start span-block-end;
--ui-popover-origin: bottom right;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="left"][data-select-align="center"] {
--ui-popover-position-area: inline-start span-all;
--ui-popover-origin: center right;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
:where([data-ui~="select"])[data-select-side="left"][data-select-align="end"] {
--ui-popover-position-area: inline-start span-block-start;
--ui-popover-origin: top right;
--ui-popover-my: 0;
--ui-popover-mx: calc(var(--spacing) * 1.5);
}
}// 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 };Behavior
primitives/select/multiselect.js
<ui-multiselect>: a multi-select styled as a dropdown. 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 asdata-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).
Attributes
| Attribute | Kind | Values | Notes |
|---|---|---|---|
data-ui="multiselect" | Identity | free value | Identity token in data-ui. |
data-ui="select" | Identity | free value | Identity token in data-ui. |
<ui-multiselect> | Tag form | free value | Tag form; styled and, when scripted, registered under this name. |
data-select-align | Preset | center end start | Preset: a named bundle of values. |
data-select-side | Preset | bottom left right top | Preset: a named bundle of values. |
data-multiselect-slot | Slot | icon label option panel trigger | Slot: a part inside the primitive. |
data-multiselect-state | State | enhanced | State the kit's script writes; read it in selectors, never author it. |
data-multiselect-align | Config | free value | Configuration the kit's script reads. |
data-multiselect-label-more | Config | free value | Configuration the kit's script reads. |
data-multiselect-placeholder | Config | free value | Configuration the kit's script reads. |
data-multiselect-side | Config | free value | Configuration the kit's script reads. |
Requires: fields, popover, button.
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-option-bg | transparent | — |
--ui-option-bg--active | var(--ui-button-bg--active) | active |
--ui-option-bg--hover | var(--ui-button-bg--hover) | hover |
--ui-option-border-color | transparent | — |
--ui-option-border-color--active | transparent | active |
--ui-option-border-color--hover | transparent | hover |
--ui-option-border-style | var(--ui-button-border-style) | — |
--ui-option-border-width | var(--ui-button-border-width) | — |
--ui-option-checkmark-color | currentColor | — |
--ui-option-checkmark-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%3Cpolyline points='40 144 96 200 224 72' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E") | — |
--ui-option-checkmark-size | var(--ui-field-icon-size) | — |
--ui-option-font-family | var(--ui-button-font-family) | — |
--ui-option-font-size | var(--ui-button-font-size) | — |
--ui-option-font-weight | var(--ui-button-font-weight) | — |
--ui-option-gap | var(--ui-button-gap) | — |
--ui-option-icon-size | var(--ui-button-icon-size) | — |
--ui-option-leading | var(--ui-button-leading) | — |
--ui-option-min-h | var(--ui-button-min-h) | — |
--ui-option-px | var(--ui-button-px) | — |
--ui-option-rounded | var(--ui-menu-button-rounded) | — |
--ui-option-shadow | none | — |
--ui-option-text | var(--ui-button-text) | — |
--ui-option-text--active | var(--ui-button-text--active) | active |
--ui-option-text--hover | var(--ui-button-text--hover) | hover |
--ui-select-border-color | var(--ui-field-border-color) | — |
--ui-select-border-style | var(--ui-field-border-style) | — |
--ui-select-border-width | var(--ui-field-border-width) | — |
--ui-select-font-size | var(--ui-field-font-size) | — |
--ui-select-leading | var(--ui-field-leading) | — |
--ui-select-min-w | 16ch | — |
--ui-select-option-gap | var(--ui-field-option-gap) | — |
--ui-select-picker-icon-color | currentColor | — |
--ui-select-picker-icon-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%3Cpolyline points='80 176 128 224 176 176' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3Cpolyline points='80 80 128 32 176 80' fill='none' stroke='currentColor' stroke-linecap='round' stroke-linejoin='round' stroke-width='16'/%3E%3C/svg%3E") | — |
--ui-select-picker-icon-size | var(--ui-field-icon-size) | — |
--ui-select-pl | var(--ui-field-px) | — |
--ui-select-pr | calc(var(--spacing) * 2) | — |
--ui-select-rounded | var(--ui-field-rounded) | — |
--ui-select-w | 100% | — |