Theme
On this page (9)
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.

§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 } from '../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const comboboxStates = ['default', 'open'];
/**
 * 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 = {
  setState(popover, stateName, config = {}) {
    if (!comboboxStates.includes(stateName)) {
      throw new Error(`combobox: unknown state "${stateName}" (supported: ${comboboxStates.join(', ')})`);
    }
    triggerStateChange(popover, stateName, config);
    // state lives on the ELEMENT, not the module (many comboboxes per page)
    popover.dataset.stateName = stateName;
    popover._stateConfig = config;
  },
  getState(popover) {
    const selected = Array.from(popover.querySelectorAll('[role="option"][aria-selected="true"]')) 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: {
        ...popover._stateConfig,
        value: labels.join(', '),
        values: selected.map((o) => o.dataset.value ?? o.textContent?.trim() ?? ''),
        labels,
      },
    };
  },
};
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 = wrapper.querySelector('.combobox-field') as HTMLElement | null;
  const input = wrapper.querySelector('.combobox-field-input') as HTMLInputElement | null;
  const popover = wrapper.querySelector('.combobox-content') as HTMLElement | null;
  const listbox = wrapper.querySelector('[role="listbox"]') as HTMLElement | null;
  if (!field || !input || !popover || !listbox) return;
  const empty = wrapper.querySelector('.combobox-empty') as HTMLElement | null;
  const creatable = wrapper.hasAttribute('data-creatable');
  const uid = wrapper.id || popover.id || `dfcb-${++comboSeq}`;
  const options = () => Array.from(listbox.querySelectorAll('[role="option"]:not(.combobox-create)')) 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;
  (popover as any).api = {
    setState: (stateName: string, config?: Record<string, unknown>) => comboboxApi.setState(popover, stateName, config),
    getState: () => comboboxApi.getState(popover),
  };
  /** 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;
    if (announce) wrapper.dispatchEvent(new CustomEvent('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() {
  document.querySelectorAll('.combobox:not([data-init])').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') || document.getElementById(trigger.getAttribute('aria-labelledby') || '')?.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 = Array.from(tags!.querySelectorAll('.combobox-tag-remove')) as HTMLElement[];
        const at = all.indexOf(btn);
        if (option) dfDollar(option).attr('aria-selected', 'false');
        renderSelection();
        const rest = Array.from(tags!.querySelectorAll('.combobox-tag-remove')) 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('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')`
    popover.api = {
      setState: (stateName, config) => comboboxApi.setState(popover, stateName, config),
      getState: () => comboboxApi.getState(popover),
    };
    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; 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;
        }
        dfDollar(label).prop('hidden', !groupHasVisible);
      });
      $listbox.find('.combobox-separator').each(function (this: HTMLElement) { const sep = this; const prev = sep.previousElementSibling; const next = sep.nextElementSibling; 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.closest('[role="option"]'); if (item && !item.hidden && item.getAttribute('aria-disabled') !== 'true') selectItem(item); });
    listbox.addEventListener('mousemove', (e) => { const item = e.target.closest('[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