Skip to content

Type to search the docs, API, and blog.

↑↓ navigate↵ openesc close
Zazz 0.7 alpha
API navigation

API

Select

The native `<select>`, restyled, and a multiselect with tags.

Installation

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

Also 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 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).

Attributes

select attributes
AttributeKindValuesNotes
data-ui="multiselect"Identityfree valueIdentity token in data-ui.
data-ui="select"Identityfree valueIdentity token in data-ui.
<ui-multiselect>Tag formfree valueTag form; styled and, when scripted, registered under this name.
data-select-alignPresetcenter end startPreset: a named bundle of values.
data-select-sidePresetbottom left right topPreset: a named bundle of values.
data-multiselect-slotSloticon label option panel triggerSlot: a part inside the primitive.
data-multiselect-stateStateenhancedState the kit's script writes; read it in selectors, never author it.
data-multiselect-alignConfigfree valueConfiguration the kit's script reads.
data-multiselect-label-moreConfigfree valueConfiguration the kit's script reads.
data-multiselect-placeholderConfigfree valueConfiguration the kit's script reads.
data-multiselect-sideConfigfree valueConfiguration 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.

select hooks
HookDefaultState
--ui-option-bgtransparent—
--ui-option-bg--activevar(--ui-button-bg--active)active
--ui-option-bg--hovervar(--ui-button-bg--hover)hover
--ui-option-border-colortransparent—
--ui-option-border-color--activetransparentactive
--ui-option-border-color--hovertransparenthover
--ui-option-border-stylevar(--ui-button-border-style)—
--ui-option-border-widthvar(--ui-button-border-width)—
--ui-option-checkmark-colorcurrentColor—
--ui-option-checkmark-maskurl("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-sizevar(--ui-field-icon-size)—
--ui-option-font-familyvar(--ui-button-font-family)—
--ui-option-font-sizevar(--ui-button-font-size)—
--ui-option-font-weightvar(--ui-button-font-weight)—
--ui-option-gapvar(--ui-button-gap)—
--ui-option-icon-sizevar(--ui-button-icon-size)—
--ui-option-leadingvar(--ui-button-leading)—
--ui-option-min-hvar(--ui-button-min-h)—
--ui-option-pxvar(--ui-button-px)—
--ui-option-roundedvar(--ui-menu-button-rounded)—
--ui-option-shadownone—
--ui-option-textvar(--ui-button-text)—
--ui-option-text--activevar(--ui-button-text--active)active
--ui-option-text--hovervar(--ui-button-text--hover)hover
--ui-select-border-colorvar(--ui-field-border-color)—
--ui-select-border-stylevar(--ui-field-border-style)—
--ui-select-border-widthvar(--ui-field-border-width)—
--ui-select-font-sizevar(--ui-field-font-size)—
--ui-select-leadingvar(--ui-field-leading)—
--ui-select-min-w16ch—
--ui-select-option-gapvar(--ui-field-option-gap)—
--ui-select-picker-icon-colorcurrentColor—
--ui-select-picker-icon-maskurl("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-sizevar(--ui-field-icon-size)—
--ui-select-plvar(--ui-field-px)—
--ui-select-prcalc(var(--spacing) * 2)—
--ui-select-roundedvar(--ui-field-rounded)—
--ui-select-w100%—