Theme
Design your own
On this page (10)
Component Skill — components/combobox/component-skill.md

Native basis

Button trigger + popover popup containing a search input and role="listbox".

Web Platform APIs

Popover APICSS Anchor Positioning@starting-styleoverscroll-behavior:has()WAI-ARIA Combobox pattern

Classes

.combobox.combobox-trigger.combobox-value.combobox-chevron.combobox-clear.combobox-content.combobox-search.combobox-search-icon.combobox-search-input.combobox-listbox.combobox-item.combobox-empty.combobox-group-label.combobox-separator

Data attributes

• data-placeholder - on .combobox-value; present when showing placeholder text, removed on selection

• data-highlighted - on .combobox-item; JS-managed, set on the currently keyboard-highlighted option

• data-value - on .combobox-item; the programmatic value of the option (used by JS, not CSS)

Keyboard

KeyBehaviorArrowDownHighlight next itemArrowUpHighlight previous itemHomeHighlight first visible itemEndHighlight last visible itemEnterSelect highlighted item, close popupEscapeClose popup, return focus to triggerTabClose popup, move focus to next elementTypingFilter items, auto-highlight first match

Notes

• The trigger is a .btn[data-variant="outline"] - styled by the button system

• When the popover opens, focus moves to the search input inside

• When the popover closes, focus returns to the trigger button

• Use aria-activedescendant to communicate the highlighted item to screen readers

• The popover attribute enables top-layer rendering and light-dismiss

• CSS anchor positioning places the popover below the trigger; no JS positioning needed

• The popover animates in via @starting-style + transition-behavior: allow-discrete

• The check icon for selected items uses a CSS ::before pseudo-element

• Filter matching is case-insensitive and supports substring matching

• A clear (✕) button is injected on the trigger once a value is selected, replacing the chevron; clicking it restores the placeholder

• Group labels and separators auto-hide when their group has no visible items

• overscroll-behavior: contain prevents scroll chaining from the listbox

• prefers-reduced-motion: reduce disables all transitions

• forced-colors: active supports Windows High Contrast Mode

§Basic Combobox

An outline button opens a popover with a search field and selectable options. Click the trigger or use keyboard to interact.

§Grouped with Disabled Items

Combobox with option groups, visual separators, and a disabled option. Use .combobox-group-label for headings and aria-disabled='true' to disable individual items.

§Multi-select

data-multiple on the .combobox picks several options: a click or Enter toggles an option and the list stays open, each option shows a checkbox (the listbox is aria-multiselectable), and the choices appear as removable tags under the trigger. Backspace in the empty search removes the last tag; the clear button empties all. data-name renders one hidden input per value, so the form submits every tag; each change fires combobox:change { values, labels }.

§Tag input

data-tags puts the tags INSIDE the field and you type right there: the list opens and filters as you type. Enter (or a comma) picks the exact match - case doesn't matter - or, with data-creatable, creates a new tag from what you typed (the 'Create …' row). Arrow keys pick any other listed option instead. Backspace in the empty field removes the last tag, Escape closes the list. Created tags become options too; data-name submits every tag.

§Disabled

Disable the trigger button to prevent interaction.

§Sizes

Set data-size on the .combobox wrapper; trigger and list rows scale together - the default equals the .btn md step.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - closed (the authored state; the trigger toggles it)
  • open - listbox shown via showPopover() with the search input focused; getState().config.value reports the selected option

The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/combobox-{state}.png.

Machine contract - verified against combobox.schema.json by bun run verify:

StateTypeValuesDefaultDescription
openbooleantrue, falsefalseListbox shown - runtime _open() highlights the first option and anchors the panel.

§API

Generated from combobox.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type ComboboxState = 'default' | 'open' - setState(name, config) takes the config of the state it names.

StateDescription
default
The list closed.
Config fieldTypeDescription
value?stringreported by getState(): the chosen label (several joined with ", ")
values?string[]reported by getState(): every chosen option's data-value (else its text), in list order
labels?string[]reported by getState(): the chosen options' texts, in the same order
open
The list open (the popover shown).
Config fieldTypeDescription
value?stringreported by getState(): the chosen label (several joined with ", ")
values?string[]reported by getState(): every chosen option's data-value (else its text), in list order
labels?string[]reported by getState(): the chosen options' texts, in the same order

Every element

MemberDescription
el.api.setState<S extends ComboboxState>(name: S, config?: ComboboxStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?ComboboxStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: ComboboxState; config: ComboboxStateConfigs[ComboboxState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: ComboboxState; config: ComboboxStateConfigs[ComboboxState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: ComboboxState; config: ComboboxStateConfigs[ComboboxState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: ComboboxState; config: ComboboxStateConfigs[ComboboxState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: ComboboxState; config: ComboboxStateConfigs[ComboboxState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.comboboxApi.setState<S extends ComboboxState>(el: HTMLElement, name: S, config?: ComboboxStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?ComboboxStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.comboboxApi.getState(el: HTMLElement): { name: ComboboxState; config: ComboboxStateConfigs[ComboboxState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: ComboboxState; config: ComboboxStateConfigs[ComboboxState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.comboboxApi.render(state: { name: ComboboxState; config: ComboboxStateConfigs[ComboboxState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: ComboboxState; config: ComboboxStateConfigs[ComboboxState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.comboboxApi.store(el: HTMLElement): Store<{ name: ComboboxState; config: ComboboxStateConfigs[ComboboxState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: ComboboxState; config: ComboboxStateConfigs[ComboboxState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.comboboxApi.commit<S extends ComboboxState>(el: HTMLElement, name: S, config?: ComboboxStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?ComboboxStateConfigs[S]its config
df$.shadcn.comboboxStates: ComboboxState[]The declared states, 'default' first: default, open.

Events

EventDescription
combobox:change
Fires when the user changes the selection - the selected values, their labels, and the values created from typed text.

detail: ComboboxChangeDetail

FieldTypeDescription
valuesstring[]the values of the selected options, in option order
labelsstring[]their labels, in the same order
created?string | nulla value just created from typed text (multiple + data-creatable), null when none; absent on a single-select

Types

TypeDescription
ComboboxChangeDetail
What combobox:change carries.
FieldTypeDescription
valuesstring[]the values of the selected options, in option order
labelsstring[]their labels, in the same order
created?string | nulla value just created from typed text (multiple + data-creatable), null when none; absent on a single-select

§CSS view file

/* -- Combobox component ---------------------------------------- */
@layer components {
  .combobox {
    position: relative;
    /* -- Sizes: set data-size on the .combobox wrapper; the trigger (a
       .btn) and the popover's rows scale together. Heights/fonts mirror the
       field ladder the .input defines: the unsized trigger renders 2.5rem —
       the .input default - via the .combobox-trigger rule below, md = 2.25rem
       is the ladder's md, exactly like .input goes 2.5rem → 2.25rem at md. */
    &[data-size="xs"] {
      & .combobox-trigger { height: 1.75rem; padding: 0 0.5rem; font-size: 0.75rem; }
      & .combobox-item, & .combobox-group-label { font-size: 0.75rem; padding-block: 0.25rem; }
    }
    &[data-size="sm"] {
      & .combobox-trigger { height: 2rem; padding: 0 0.625rem; font-size: 0.8125rem; }
      & .combobox-item, & .combobox-group-label { font-size: 0.8125rem; padding-block: 0.3125rem; }
    }
    &[data-size="md"] {
      & .combobox-trigger { height: 2.25rem; }
    }
    &[data-size="lg"] {
      & .combobox-trigger { height: 2.75rem; padding: 0 1rem; font-size: 1rem; }
      & .combobox-item, & .combobox-group-label { font-size: 1rem; padding-block: 0.5rem; }
    }
    &[data-size="xl"] {
      & .combobox-trigger { height: 3.25rem; padding: 0 1.25rem; font-size: 1.125rem; }
      & .combobox-item, & .combobox-group-label { font-size: 1.125rem; padding-block: 0.625rem; }
    }
  }
  .combobox-trigger {
    width: 100%;
    justify-content: space-between;
    /* the field standard for the unsized default (see .input): the ladder's
       md step - identical to what a bare .btn renders, so no override is
       needed beyond pinning it explicitly against .btn base drift */
    height: 2.25rem;
  }
  .combobox-value {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    flex: 1;
    text-align: left;
    font-weight: 400;
  }
  .combobox-value[data-placeholder] {
    color: var(--muted-foreground);
  }
  .combobox-chevron {
    flex-shrink: 0;
    color: var(--muted-foreground);
    opacity: 0.5;
  }
  /* Clear button (injected by combobox.js after the trigger). Anchor-positioned
     over the trigger's chevron slot; anchored via the same per-instance anchor
     name the popover uses. `data-placeholder` presence is the single
     selection marker - the :has() rules below flip chevron and clear in sync,
     so no JS toggles visibility. */
  .combobox-clear {
    position: fixed;
    inset: auto;
    margin: 0;
    top: anchor(top);
    bottom: anchor(bottom);
    right: anchor(right);
    /* center the 20px box exactly over the 16px chevron (1rem trigger padding) */
    margin-inline-end: 0.875rem;
    width: 1.25rem;
    display: none;
    place-items: center;
    padding: 0;
    border: none;
    border-radius: var(--radius-sm);
    background: transparent;
    color: var(--muted-foreground);
    cursor: pointer;
    &:hover { color: var(--foreground); }
    &:focus-visible { outline: 2px solid var(--ring); outline-offset: 1px; }
  }
  .combobox:has(.combobox-value:not([data-placeholder])) {
    & .combobox-clear { display: grid; }
    & .combobox-chevron { display: none; }
  }
  /* -- Multi-select (data-multiple) --------------------------------
     Options show a checkbox (empty box → filled with a check), the chosen
     options sit as removable tags under the trigger. The check is ::after,
     painted in --primary-foreground over the ::before box, so it stays
     visible in light and dark (primary flips, its foreground flips with it). */
  .combobox[data-multiple] .combobox-item {
    padding-inline-start: 1.875rem;
    &::before {
      content: '';
      position: absolute;
      inset-inline-start: 0.5rem;
      top: 50%;
      width: 1rem;
      height: 1rem;
      transform: translateY(-50%);
      box-sizing: border-box;
      border: 1px solid var(--input);
      border-radius: calc(var(--radius-sm) * 0.75);
      background: var(--background);
      mask: none;
    }
    &[aria-selected="true"]::before {
      border-color: var(--primary);
      background-color: var(--primary);
      mask: none;
    }
    &[aria-selected="true"]::after {
      content: '';
      position: absolute;
      inset-inline-start: 0.625rem;
      top: 50%;
      width: 0.75rem;
      height: 0.75rem;
      transform: translateY(-50%);
      background-color: var(--primary-foreground);
      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E") center / contain no-repeat;
    }
  }
  .combobox-tags {
    display: flex;
    flex-wrap: wrap;
    gap: 0.375rem;
    margin-top: 0.5rem;
    /* nothing chosen: no empty gap under the trigger */
    &:not(:has(.combobox-tag)) {
      display: none;
    }
  }
  .combobox-tag {
    display: inline-flex;
    align-items: center;
    gap: 0.25rem;
    max-width: 100%;
    padding: 0.125rem 0.25rem 0.125rem 0.5rem;
    border-radius: var(--radius-sm);
    background-color: var(--secondary);
    color: var(--secondary-foreground);
    font-size: 0.75rem;
    font-weight: 500;
    line-height: 1.25rem;
  }
  .combobox-tag-remove {
    display: inline-grid;
    place-items: center;
    width: 1rem;
    height: 1rem;
    padding: 0;
    border: none;
    border-radius: calc(var(--radius-sm) * 0.75);
    background: transparent;
    color: var(--muted-foreground);
    cursor: pointer;
    &:hover {
      background-color: color-mix(in oklch, var(--foreground) 10%, transparent);
      color: var(--foreground);
    }
    &:focus-visible {
      outline: 2px solid var(--ring);
      outline-offset: 1px;
    }
  }
  /* -- Tag input (data-tags) ----------------------------------------
     Tags INSIDE a field-like box, typed into directly: .combobox-field looks
     like .input (same border, radius, ring on focus-within), the tags and the
     text input wrap together, the input takes the remaining width. */
  .combobox-field {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.25rem;
    box-sizing: border-box;
    min-height: 2.25rem;
    padding: 0.25rem 0.5rem;
    border: 1px solid var(--input);
    border-radius: var(--radius-md);
    background: var(--background);
    box-shadow: var(--shadow-xs);
    cursor: text;
    transition: border-color 150ms, box-shadow 150ms;
    &:focus-within {
      border-color: var(--ring);
      box-shadow: 0 0 0 2px oklch(from var(--ring) l c h / 0.2);
    }
    /* the tag span dissolves into the field's wrap (tags + input flow together) */
    & > .combobox-tags {
      display: contents;
    }
    & .combobox-tag {
      line-height: 1.375rem;
    }
  }
  .combobox-field-input {
    flex: 1 1 6rem;
    min-width: 6rem;
    height: 1.625rem;
    padding: 0 0.25rem;
    border: none;
    outline: none;
    background: transparent;
    color: var(--foreground);
    font: inherit;
    font-size: 0.875rem;
    &::placeholder {
      color: var(--muted-foreground);
    }
  }
  /* "Create …" row: a plus instead of the checkbox */
  .combobox[data-multiple] .combobox-item.combobox-create {
    color: var(--muted-foreground);
    &::before {
      border: none;
      border-radius: 0;
      background-color: currentColor;
      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round'%3E%3Cpath d='M12 5v14M5 12h14'/%3E%3C/svg%3E") center / contain no-repeat;
    }
    &[data-highlighted] {
      color: var(--accent-foreground);
    }
  }
  .combobox-content {
    position: fixed;
    inset: auto;
    margin: 0;
    padding: 0;
    background-color: var(--popover);
    color: var(--popover-foreground);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-md);
    overflow: hidden;
    /* -- Anchor positioning -- */
    top: anchor(bottom);
    left: anchor(left);
    width: anchor-size(width);
    margin-top: 4px;
    position-try-fallbacks: flip-block;
    /* -- Animation --------------------------------------------- */
    opacity: 0;
    transform: scale(0.96) translateY(-0.25rem);
    transition: opacity 150ms ease, transform 150ms ease,
                display 150ms allow-discrete;
    &:popover-open {
      opacity: 1;
      transform: scale(1) translateY(0);
    }
  }
  @starting-style {
    .combobox-content:popover-open {
      opacity: 0;
      transform: scale(0.96) translateY(-0.25rem);
    }
  }
  .combobox-search {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid var(--border);
  }
  .combobox-search-icon {
    flex-shrink: 0;
    color: var(--muted-foreground);
  }
  .combobox-search-input {
    width: 100%;
    border: none;
    background: transparent;
    font-size: 0.875rem;
    font-family: var(--font-sans);
    color: var(--foreground);
    outline: none;
    &::placeholder { color: var(--muted-foreground); }
  }
  .combobox-listbox {
    max-height: 16rem;
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: 0.25rem;
  }
  .combobox-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.375rem 0.5rem;
    border-radius: calc(var(--radius) * 0.6);
    font-size: 0.875rem;
    cursor: pointer;
    outline: none;
    transition: background 100ms;
    /* room for the selection check; the ::before is absolute (like
       .dropdown-check) so unselected rows never shift horizontally */
    padding-inline-start: 1.5rem;
    position: relative;
    &:hover, &[data-highlighted] {
      background-color: var(--accent);
      color: var(--accent-foreground);
    }
    /* Mask (not background-image): a data: URI SVG resolves currentColor to
       BLACK when used as an image, making the check invisible in dark mode.
       Masking paints it with background-color: currentColor - the real text
       color, hover/foreground included. */
    &[aria-selected="true"]::before {
      content: '';
      position: absolute;
      inset-inline-start: 0.375rem;
      top: 50%;
      transform: translateY(-50%);
      width: 1rem;
      height: 1rem;
      background-color: currentColor;
      mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
      mask-size: contain;
      mask-repeat: no-repeat;
      flex-shrink: 0;
    }
    &[aria-disabled="true"] {
      pointer-events: none;
      opacity: 0.5;
    }
    /* Author `display: flex` overrides the UA's [hidden] { display: none }
       (author origin always wins), so filter()'s item.hidden = true left
       non-matching options rendered - and clickable - in the list. */
    &[hidden] {
      display: none;
    }
  }
  .combobox-empty {
    padding: 1.5rem 0.5rem;
    text-align: center;
    font-size: 0.875rem;
    color: var(--muted-foreground);
  }
  .combobox-group-label {
    padding: 0.375rem 0.5rem;
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--muted-foreground);
  }
  .combobox-separator {
    height: 1px;
    background: var(--border);
    margin: 0.25rem -0.25rem;
  }
  /* -- Accessibility ------------------------------------------ */
  @media (prefers-reduced-motion: reduce) {
    .combobox-content {
      transition: none;
    }
    .combobox-item {
      transition: none;
    }
  }
  @media (forced-colors: active) {
    .combobox-content {
      border-color: ButtonText;
    }
    .combobox-item {
      &:hover, &[data-highlighted] {
        forced-color-adjust: none;
        background: Highlight;
        color: HighlightText;
      }
    }
  }
}

§JavaScript view file

Trigger opens/closes the popover. Search input filters the list. Arrow keys navigate, Enter selects, Escape closes. Focus moves to search input on open and back to trigger on close.

// -- Combobox -------------------------------------------------
// Searchable select with keyboard navigation and popover positioning, plus
// the named-state API bound per dropdown popover, so agents/tests can open
// and close it by name (AGENTS.md "State API").
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
// defussQuery: the callable runtime for scoped lookup + scalar writes
// (plans/defuss-query-morph-integration.md §3 combobox row: filtering an
// existing consumer-authored list is flag-based - NO full renderer; options
// keep node identity, only hidden/aria flags change).
import { defussGlobals, defussQuery, safeShowPopover, componentState, bindComponent } from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
// VERIFIED: (verify's component types ratchet - tsc -p tsconfig.components.json) every type
// this file's API docs state - arguments, return values, event details - holds
// against its code: a wrong one is a new type error and fails the build.
/** What combobox:change carries. */
interface ComboboxChangeDetail {
  /** the values of the selected options, in option order */
  values: string[];
  /** their labels, in the same order */
  labels: string[];
  /** a value just created from typed text (multiple + data-creatable), null when none; absent on a single-select */
  created?: string | null;
}
const comboboxStates = ['default', 'open'];
/** setState() configs per state - the states take none; getState() reports the selection. */
export interface ComboboxStateConfigs {
  /** The list closed. */
  default: {
    /** reported by getState(): the chosen label (several joined with ", ") */
    value?: string;
    /** reported by getState(): every chosen option's data-value (else its text), in list order */
    values?: string[];
    /** reported by getState(): the chosen options' texts, in the same order */
    labels?: string[];
  };
  /** The list open (the popover shown). */
  open: {
    /** reported by getState(): the chosen label (several joined with ", ") */
    value?: string;
    /** reported by getState(): every chosen option's data-value (else its text), in list order */
    values?: string[];
    /** reported by getState(): the chosen options' texts, in the same order */
    labels?: string[];
  };
}
/**
 * The markup of a state: none - 'open' lives in the top layer
 * (:popover-open), not in an attribute, so every state renders the authored
 * markup. render() stays the State API's markup function all the same.
 */
function applyMarkup(_el, _stateName) {}
/**
 * UI side of setState (per popover): 'default' closes, 'open' shows. The
 * wrapper's own open()/close() (registered at init) keep aria-expanded,
 * highlight and focus bookkeeping in one place.
 */
function triggerStateChange(popover, stateName, _config) {
  switch (stateName) {
    case 'default':
      popover._close?.();
      break;
    case 'open':
      popover._open?.();
      break;
  }
}
/** Registry-level API; pass the popover element explicitly. Unknown names throw. */
export const comboboxApi = componentState({
  component: 'combobox',
  states: comboboxStates,
  apply: (popover, state) => triggerStateChange(popover, state.name, state.config),
  read: (popover, state) => {
    const selected = Array.from(dfDollar(popover).find('[role="option"][aria-selected="true"]').toArray()) as HTMLElement[];
    const labels = selected.map((o) => o.textContent?.trim() ?? '');
    return {
      // reflect reality: trigger clicks and Escape change the UI too
      name: popover.matches(':popover-open') ? 'open' : 'default',
      // value: the chosen label (joined in multi-select); values / labels:
      // every chosen option's data-value / text, in list order
      config: {
        ...state.config,
        value: labels.join(', '),
        values: selected.map((o) => o.dataset.value ?? o.textContent?.trim() ?? ''),
        labels,
      },
    };
  },
  markup: (el, state) => applyMarkup(el, state.name),
});
df$.comboboxApi = comboboxApi;
df$.comboboxStates = comboboxStates;
/** Escape text for the tag/hidden-input markup rendered through morph. */
const esc = (t: string) =>
  t.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/** A value that is safe inside an element id. */
const idPart = (t: string) => t.replace(/[^\w-]/g, '_');
let comboSeq = 0;
/**
 * Tag input (data-tags on a data-multiple .combobox): the chosen options sit
 * as removable tags INSIDE a field-like box (.combobox-field) next to the
 * text input the user types into - no trigger button. Typing opens and
 * filters the list; Enter picks the EXACT match (case-insensitive) or, with
 * data-creatable, creates a new tag from the text ("Create …" row); arrow
 * keys can pick any other listed option instead. Comma commits like Enter,
 * Backspace in the empty input removes the last tag, Escape closes the list.
 * Created tags become real options (data-created) so they can be toggled
 * like the rest. data-name renders one hidden input per value.
 */
function initTags(wrapper: HTMLElement) {
  const field = dfDollar(wrapper).find('.combobox-field').get(0) as HTMLElement | null;
  const input = dfDollar(wrapper).find('.combobox-field-input').get(0) as HTMLInputElement | null;
  const popover = dfDollar(wrapper).find('.combobox-content').get(0) as HTMLElement | null;
  const listbox = dfDollar(wrapper).find('[role="listbox"]').get(0) as HTMLElement | null;
  if (!field || !input || !popover || !listbox) return;
  const empty = dfDollar(wrapper).find('.combobox-empty').get(0) as HTMLElement | null;
  const creatable = wrapper.hasAttribute('data-creatable');
  const uid = wrapper.id || popover.id || `dfcb-${++comboSeq}`;
  const options = () => Array.from(dfDollar(listbox).find('[role="option"]:not(.combobox-create)').toArray()) as HTMLElement[];
  const valueOf = (o: HTMLElement) => o.dataset.value ?? o.textContent!.trim();
  const labelOf = (o: HTMLElement) => o.textContent!.trim();
  dfDollar(listbox).attr('aria-multiselectable', 'true');
  // anchor the list to the whole field (not just the input)
  const anchorId = `--combobox-${uid}`;
  dfDollar(field).css('anchorName', anchorId);
  dfDollar(popover).css('positionAnchor', anchorId);
  // tags live inside the field, before the input
  const tags = document.createElement('span');
  tags.className = 'combobox-tags';
  dfDollar(input).before(tags);
  // the "Create …" row, shown while the text matches no option exactly
  let createRow: HTMLElement | null = null;
  if (creatable) {
    createRow = document.createElement('div');
    createRow.className = 'combobox-item combobox-create';
    createRow.id = `${uid}-create`;
    createRow.setAttribute('role', 'option');
    createRow.setAttribute('aria-selected', 'false');
    createRow.hidden = true;
    dfDollar(listbox).append(createRow);
  }
  let highlighted: HTMLElement | null = null;
  const highlight = (el: HTMLElement | null) => {
    if (highlighted) dfDollar(highlighted).data('highlighted', null);
    highlighted = el;
    if (el) {
      dfDollar(el).data('highlighted', '');
      el.scrollIntoView({ block: 'nearest' });
      dfDollar(input).attr('aria-activedescendant', el.id);
    } else dfDollar(input).attr('aria-activedescendant', null);
  };
  const visible = () => [...options(), ...(createRow ? [createRow] : [])].filter((o) => !o.hidden && o.getAttribute('aria-disabled') !== 'true');
  const isOpen = () => popover.matches(':popover-open');
  const open = () => {
    if (!isOpen()) safeShowPopover(popover);
    dfDollar(input).attr('aria-expanded', 'true');
  };
  const close = () => {
    if (isOpen()) popover.hidePopover();
    dfDollar(input).attr('aria-expanded', 'false');
    highlight(null);
  };
  // the State API, like every other combobox: setState('open' | 'default')
  // drives the list (the tag path returns before the main init binds it -
  // without this the docs' State "open" switch did nothing on a tag input)
  (popover as any)._open = () => { open(); filter(); };
  (popover as any)._close = close;
  // el.store + el.api (AGENTS.md "State through stores")
  bindComponent(popover, comboboxApi);
  /** Filter by the typed text; auto-highlight the exact match, else the create row. */
  const filter = () => {
    const text = input.value.trim();
    const q = text.toLowerCase();
    let exact: HTMLElement | null = null;
    let any = false;
    for (const o of options()) {
      const match = !q || labelOf(o).toLowerCase().includes(q);
      dfDollar(o).prop('hidden', !match);
      if (match) any = true;
      if (q && labelOf(o).toLowerCase() === q) exact = o;
    }
    if (createRow) {
      const showCreate = !!text && !exact;
      dfDollar(createRow).prop('hidden', !showCreate);
      if (showCreate) dfDollar(createRow).text(`Create "${text}"`); // literal text (§5.2)
    }
    if (empty) dfDollar(empty).prop('hidden', any || (!!createRow && !createRow.hidden));
    highlight(exact ?? (createRow && !createRow.hidden ? createRow : !creatable ? visible()[0] ?? null : null));
  };
  /** Tags + hidden inputs + combobox:change - the tag input's single renderer. */
  const render = (announce = true, created: string | null = null) => {
    const chosen = options().filter((o) => o.getAttribute('aria-selected') === 'true');
    const labels = chosen.map(labelOf);
    const values = chosen.map(valueOf);
    const name = wrapper.dataset.name;
    dfDollar(tags).morph(
      labels
        .map((label, i) => `<span class="combobox-tag" id="${uid}-tag-${idPart(values[i])}">${esc(label)}<button type="button" class="combobox-tag-remove" data-value="${esc(values[i])}" aria-label="Remove ${esc(label)}" tabindex="-1"><svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button></span>`)
        .join('') +
        (name ? values.map((v) => `<input type="hidden" name="${esc(name)}" value="${esc(v)}" id="${uid}-input-${idPart(v)}">`).join('') : ''),
    ); // escaped text + static icon (§5.1)
    // the placeholder only while there are no tags (it would read as a value)
    if (input.dataset.placeholder === undefined) input.dataset.placeholder = input.placeholder;
    input.placeholder = labels.length ? '' : input.dataset.placeholder;
    // Fires when the user changes the selection - the selected values, their labels, and the values created from typed text.
    if (announce) wrapper.dispatchEvent(new CustomEvent<ComboboxChangeDetail>('combobox:change', { bubbles: true, detail: { values, labels, created } }));
  };
  /** Commit: exact match / highlighted row / new tag from the text. */
  const commit = (row: HTMLElement | null) => {
    const text = input.value.trim();
    let created: string | null = null;
    // (createRow &&: without data-creatable both are null - never "create")
    if ((createRow && row === createRow) || (!row && creatable && text)) {
      if (!text) return;
      // an exact match (maybe filtered out by a stale highlight) wins over creating
      const exact = options().find((o) => labelOf(o).toLowerCase() === text.toLowerCase());
      if (exact) row = exact;
      else {
        const option = document.createElement('div');
        option.className = 'combobox-item';
        option.setAttribute('role', 'option');
        option.dataset.value = text;
        option.dataset.created = '';
        option.id = `${uid}-opt-${idPart(text)}-${options().length}`;
        dfDollar(option).text(text); // literal user text (§5.2)
        if (createRow) dfDollar(createRow).before(option);
        else dfDollar(listbox).append(option);
        row = option;
        created = text;
      }
      dfDollar(row).attr('aria-selected', 'true');
    } else if (row) {
      if (row.getAttribute('aria-disabled') === 'true') return;
      // typed to find it → add; clicked / arrowed on a chosen one → toggle off
      const on = row.getAttribute('aria-selected') === 'true';
      dfDollar(row).attr('aria-selected', on && !text ? 'false' : 'true');
    } else return;
    dfDollar(input).val('');
    render(true, created);
    filter();
    input.focus();
  };
  render(false);
  filter();
  field.addEventListener('mousedown', (e) => {
    // clicks on the box (not a tag button) focus the input
    if (!(e.target as HTMLElement).closest('button, input')) {
      e.preventDefault();
      input.focus();
    }
  });
  tags.addEventListener('click', (e) => {
    const btn = (e.target as HTMLElement).closest('.combobox-tag-remove') as HTMLElement | null;
    if (!btn) return;
    const option = options().find((o) => valueOf(o) === btn.dataset.value);
    if (option) dfDollar(option).attr('aria-selected', 'false');
    render();
    filter();
    input.focus();
  });
  input.addEventListener('focus', () => { open(); filter(); });
  input.addEventListener('input', () => { open(); filter(); });
  input.addEventListener('keydown', (e) => {
    const rows = visible();
    const at = highlighted ? rows.indexOf(highlighted) : -1;
    switch (e.key) {
      case 'ArrowDown': e.preventDefault(); open(); highlight(rows[Math.min(at + 1, rows.length - 1)] ?? null); break;
      case 'ArrowUp': e.preventDefault(); highlight(rows[Math.max(at - 1, 0)] ?? null); break;
      case 'Enter': e.preventDefault(); commit(highlighted); break;
      case ',': if (input.value.trim()) { e.preventDefault(); commit(highlighted); } break;
      case 'Backspace': {
        if (input.value !== '') break;
        const chosen = options().filter((o) => o.getAttribute('aria-selected') === 'true');
        const last = chosen[chosen.length - 1];
        if (!last) break;
        e.preventDefault();
        dfDollar(last).attr('aria-selected', 'false');
        render();
        filter();
        break;
      }
      case 'Escape': e.preventDefault(); close(); break;
      case 'Tab': close(); break;
    }
  });
  // pointer picks keep focus in the input (mousedown default would blur it)
  listbox.addEventListener('mousedown', (e) => e.preventDefault());
  listbox.addEventListener('click', (e) => {
    const row = (e.target as HTMLElement).closest('[role="option"]') as HTMLElement | null;
    if (row && !row.hidden) commit(row);
  });
  listbox.addEventListener('mousemove', (e) => {
    const row = (e.target as HTMLElement).closest('[role="option"]') as HTMLElement | null;
    if (row && !row.hidden && row !== highlighted) highlight(row);
  });
  // leaving the whole widget closes the list
  wrapper.addEventListener('focusout', (e) => {
    if (!wrapper.contains(e.relatedTarget as Node) && !popover.contains(e.relatedTarget as Node)) close();
  });
  // toggle events are queued: a close fired just before a re-open arrives after
  // it - mirror the popover's REAL state instead of assuming "closed"
  popover.addEventListener('toggle', () => { dfDollar(input).attr('aria-expanded', String(isOpen())); });
}
function init() {
  dfDollar('.combobox:not([data-init])').toArray().forEach((wrapper) => {
    wrapper.dataset.init = '';
    if (wrapper.hasAttribute('data-tags')) {
      initTags(wrapper as HTMLElement);
      return;
    }
    // scoped lookup through query (§3 direct integration); raw refs below are
    // kept only for native protocols (showPopover/focus/anchor wiring)
    const $wrapper = dfDollar(wrapper);
    const $trigger = $wrapper.find('.combobox-trigger');
    const $value = $wrapper.find('.combobox-value');
    const $popover = $wrapper.find('.combobox-content');
    const $search = $wrapper.find('.combobox-search-input');
    const $listbox = $wrapper.find('[role="listbox"]');
    const $empty = $wrapper.find('.combobox-empty');
    const trigger = $trigger[0] as HTMLElement | undefined;
    const popover = $popover[0] as HTMLElement | undefined;
    const searchInput = $search[0] as HTMLInputElement | undefined;
    const listbox = $listbox[0] as HTMLElement | undefined;
    if (!trigger || !popover || !searchInput || !listbox) return;
    // options are consumer-authored: a snapshot selection, flagged in place
    const allItems = $listbox.find('[role="option"]');
    let highlighted = -1;
    // CSS anchor positioning - unique name per trigger-popover pair
    const anchorId = `--combobox-${popover.id}`;
    $trigger.css('anchorName', anchorId);
    $popover.css('positionAnchor', anchorId);
    // Clear button - injected so consumer markup stays minimal (and the
    // button can't be nested in the trigger's <button>). Visibility is pure
    // CSS: .combobox-clear shows exactly while data-placeholder is absent
    // (combobox.css :has() rule); JS only wires the click and focus.
    // Trusted static icon markup (sanctioned §5.1 exception); inserted via
    // query's exact .after() so lifecycle goes through one adapter.
    const placeholder = $value.data('placeholder') ?? '';
    const clearBtn = document.createElement('button');
    clearBtn.type = 'button';
    clearBtn.className = 'combobox-clear';
    clearBtn.setAttribute('aria-label', 'Clear selection');
    dfDollar(clearBtn).html(
      '<svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>',
    ); // trusted static icon markup (§5.1)
    dfDollar(clearBtn).css('positionAnchor', anchorId);
    $trigger.after(clearBtn);
    // -- Multi-select (data-multiple) ----------------------------------------
    // Several options at once: toggling keeps the popover open, the chosen
    // options show as removable tags BELOW the trigger (buttons may not nest
    // inside the trigger <button>), and data-name renders one hidden input
    // per value so the choice submits with the form.
    const multiple = wrapper.hasAttribute('data-multiple');
    const uid = wrapper.id || popover.id || `dfcb-${++comboSeq}`;
    let tags: HTMLElement | null = null;
    if (multiple) {
      $listbox.attr('aria-multiselectable', 'true');
      tags = document.createElement('div');
      tags.className = 'combobox-tags';
      tags.setAttribute('role', 'list');
      tags.setAttribute('aria-label', `Selected ${trigger.getAttribute('aria-label') || dfDollar('#' + CSS.escape(trigger.getAttribute('aria-labelledby') || '')).get(0)?.textContent?.trim() || 'options'}`);
      dfDollar(clearBtn).after(tags);
      // a tag's × removes its option; focus moves to the neighbouring tag
      // (or back to the trigger) so keyboard users never lose their place
      tags.addEventListener('click', (e) => {
        const btn = (e.target as HTMLElement).closest('.combobox-tag-remove') as HTMLElement | null;
        if (!btn) return;
        const option = Array.from(allItems).find((o) => (o.dataset.value ?? o.textContent.trim()) === btn.dataset.value);
        const all = dfDollar(tags!).find('.combobox-tag-remove').toArray() as HTMLElement[];
        const at = all.indexOf(btn);
        if (option) dfDollar(option).attr('aria-selected', 'false');
        renderSelection();
        const rest = dfDollar(tags!).find('.combobox-tag-remove').toArray() as HTMLElement[];
        (rest[Math.min(at, rest.length - 1)] ?? trigger).focus();
      });
    }
    /**
     * Mirror the selection into the trigger label (placeholder when empty,
     * the chosen labels otherwise), the tags + hidden inputs (multi), and
     * announce it as combobox:change { values, labels }.
     */
    const renderSelection = (announce = true) => {
      const chosen = Array.from(allItems).filter((o) => o.getAttribute('aria-selected') === 'true');
      const labels = chosen.map((o) => o.textContent.trim());
      const values = chosen.map((o) => o.dataset.value ?? o.textContent.trim());
      // multi: the tags below already list every choice - the trigger shows a
      // summary instead of repeating them: the one label, else "{n} selected"
      // (data-selected-label on .combobox-value translates it, {n} = count)
      const summary = multiple && labels.length > 1
        ? ($value.data('selectedLabel') ?? '{n} selected').replace('{n}', String(labels.length))
        : labels.join(', ');
      if (labels.length) $value.text(summary).attr('data-placeholder', null);
      else $value.text(placeholder).attr('data-placeholder', placeholder);
      if (tags) {
        const name = wrapper.dataset.name;
        dfDollar(tags).morph(
          labels
            .map((label, i) => `<span class="combobox-tag" role="listitem" id="${uid}-tag-${idPart(values[i])}">${esc(label)}<button type="button" class="combobox-tag-remove" data-value="${esc(values[i])}" aria-label="Remove ${esc(label)}"><svg aria-hidden="true" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button></span>`)
            .join('') +
            (name ? values.map((v) => `<input type="hidden" name="${esc(name)}" value="${esc(v)}" id="${uid}-input-${idPart(v)}">`).join('') : ''),
        ); // escaped option text + static icon (§5.1: text is never parsed as markup)
      }
      if (announce) wrapper.dispatchEvent(new CustomEvent<ComboboxChangeDetail>('combobox:change', { bubbles: true, detail: { values, labels } }));
    };
    // authored aria-selected="true" options are the initial selection
    if (multiple) renderSelection(false);
    clearBtn.addEventListener('click', () => {
      allItems.attr('aria-selected', 'false');
      // placeholder text + data-placeholder are re-declared by renderSelection:
      // the latter is the very marker the CSS :has() rule keys off to hide
      // this button again
      renderSelection();
      // the button goes display:none with the selection - keep focus usable
      trigger.focus();
    });
    const getVisibleItems = () => allItems.filter((item) => !item.hidden && item.getAttribute('aria-disabled') !== 'true');
    const open = () => {
      // deferred show (safeShowPopover): showPopover() mid-exit crashes the
      // headless renderer; hide-then-show is deterministic everywhere.
      safeShowPopover(popover);
      $trigger.attr('aria-expanded', 'true');
      $search.val('');
      filter('');
      searchInput.focus();
    };
    const close = () => {
      popover.hidePopover();
      $trigger.attr('aria-expanded', 'false');
      $search.attr('aria-activedescendant', '');
      clearHighlight();
      trigger.focus();
    };
    // expose for the State API (element members, not module scope)
    popover._open = open;
    popover._close = close;
    // bind-scope the api per popover: `$('#cb-popover').api.setState('open')`
    // el.store + el.api (AGENTS.md "State through stores")
    bindComponent(popover, comboboxApi);
    const isOpen = () => popover.matches(':popover-open');
    // flag-based filtering: hidden props toggle IN PLACE (nodes are never
    // replaced - identity, focus and caret survive), per §3's no-renderer rule
    const filter = (query) => {
      const q = query.toLowerCase(); let hasVisible = false;
      allItems.forEach((item) => { const match = !q || item.textContent.trim().toLowerCase().includes(q); dfDollar(item).prop('hidden', !match); if (match) hasVisible = true; });
      $listbox.find('.combobox-group-label').each(function (this: HTMLElement) {
        const label = this;
        let next = label.nextElementSibling as HTMLElement | null; let groupHasVisible = false;
        while (next && !next.classList.contains('combobox-group-label') && !next.classList.contains('combobox-separator')) {
          if (next.getAttribute('role') === 'option' && !next.hidden) groupHasVisible = true; next = next.nextElementSibling as HTMLElement | null;
        }
        dfDollar(label).prop('hidden', !groupHasVisible);
      });
      $listbox.find('.combobox-separator').each(function (this: HTMLElement) { const sep = this; const prev = sep.previousElementSibling as HTMLElement | null; const next = sep.nextElementSibling as HTMLElement | null; dfDollar(sep).prop('hidden', Boolean((prev && prev.hidden) || (next && next.hidden))); });
      if ($empty.length) $empty.prop('hidden', hasVisible);
    };
    const clearHighlight = () => { allItems.data('highlighted', null); highlighted = -1; };
    const doHighlight = (index) => {
      const items = getVisibleItems(); clearHighlight();
      if (index < 0 || index >= items.length) return;
      highlighted = index; dfDollar(items[index]).data('highlighted', '');
      items[index].scrollIntoView({ block: 'nearest' });
      $search.attr('aria-activedescendant', items[index].id);
    };
    const selectItem = (item) => {
      if (item.getAttribute('aria-disabled') === 'true') return;
      if (multiple) {
        // toggle, keep the list open and the search focused for the next pick
        dfDollar(item).attr('aria-selected', item.getAttribute('aria-selected') === 'true' ? 'false' : 'true');
        renderSelection();
        searchInput.focus();
        return;
      }
      allItems.attr('aria-selected', 'false');
      dfDollar(item).attr('aria-selected', 'true');
      // trigger label mirrors the option's literal text (§3: query .text())
      renderSelection();
      close();
    };
    trigger.addEventListener('click', () => { if (isOpen()) { close(); } else { open(); } });
    searchInput.addEventListener('input', () => { filter(searchInput.value); doHighlight(0); });
    searchInput.addEventListener('keydown', (e) => {
      const items = getVisibleItems();
      switch (e.key) {
        case 'ArrowDown': e.preventDefault(); doHighlight(Math.min(highlighted + 1, items.length - 1)); break;
        case 'ArrowUp': e.preventDefault(); doHighlight(Math.max(highlighted - 1, 0)); break;
        case 'Home': e.preventDefault(); doHighlight(0); break;
        case 'End': e.preventDefault(); doHighlight(items.length - 1); break;
        case 'Enter': e.preventDefault(); if (highlighted >= 0 && items[highlighted]) selectItem(items[highlighted]); break;
        case 'Escape': e.preventDefault(); close(); break;
        case 'Tab': close(); break;
        case 'Backspace': {
          // multi: Backspace in an empty search removes the last chosen option
          if (!multiple || searchInput.value !== '') break;
          const chosen = Array.from(allItems).filter((o) => o.getAttribute('aria-selected') === 'true');
          const last = chosen[chosen.length - 1];
          if (!last) break;
          e.preventDefault();
          dfDollar(last).attr('aria-selected', 'false');
          renderSelection();
          break;
        }
      }
    });
    listbox.addEventListener('click', (e) => { const item = (e.target as HTMLElement).closest<HTMLElement>('[role="option"]'); if (item && !item.hidden && item.getAttribute('aria-disabled') !== 'true') selectItem(item); });
    listbox.addEventListener('mousemove', (e) => { const item = (e.target as HTMLElement).closest<HTMLElement>('[role="option"]'); if (item && !item.hidden) { const items = getVisibleItems(); doHighlight(items.indexOf(item)); } });
    popover.addEventListener('toggle', () => {
      // queued toggle events may arrive after a re-open - mirror the real state
      const nowOpen = popover.matches(':popover-open');
      $trigger.attr('aria-expanded', String(nowOpen));
      if (!nowOpen) clearHighlight();
    });
  });
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub