Skip to content

cv-combobox

Combobox input with popup listbox, supporting editable and select-only modes, single and multi-select, clearable behavior, and grouped options.

Headless: createCombobox

Cross-Spec Consistency

This document is the UIKit surface contract for Combobox.

  • Headless createCombobox is the source of truth for state, transitions, and invariants.
  • UIKit mirrors headless contracts through DOM attributes and events.
  • Any intentional divergence between UIKit and headless MUST be documented in both specs.

Usage

View source
html
<div class="combobox-demo-shell" data-demo="combobox" data-live-demo-height="1180">
  <section class="combobox-demo-hero" aria-labelledby="combobox-demo-title">
    <div class="combobox-demo-copy">
      <span class="combobox-demo-kicker">Search and select primitive</span>
      <h3 id="combobox-demo-title">
        Use combobox when a controlled choice also needs filtering, grouping, or tags.
      </h3>
      <p>
        The headless model owns input value, active option, popup state, selection, and ARIA contracts. UIKit
        renders the editable field, select-only trigger, clear control, grouped listbox, and selected tag
        surface.
      </p>
    </div>

    <dl class="combobox-demo-metrics" aria-label="Combobox contract summary">
      <div>
        <dt>Modes</dt>
        <dd>editable / select-only</dd>
      </div>
      <div>
        <dt>Selection</dt>
        <dd>single / multiple / grouped</dd>
      </div>
      <div>
        <dt>Events</dt>
        <dd>input / change / clear</dd>
      </div>
    </dl>
  </section>

  <section class="combobox-demo-board" aria-label="Combobox examples in a vault routing form">
    <form class="combobox-demo-panel" data-combobox-form>
      <div class="combobox-demo-panel-head">
        <div>
          <span>Visible profile routing</span>
          <strong>relay.surface / browser tags / operator handoff</strong>
        </div>
        <cv-badge variant="primary" pill>live contract</cv-badge>
      </div>

      <div class="combobox-demo-field-grid">
        <cv-field required>
          <span slot="label">Visible route</span>
          <cv-combobox
            data-combobox-primary
            clearable
            value="relay"
            input-value="Relay endpoint"
            placeholder="Search routes"
            aria-label="Visible route"
          >
            <span slot="prefix" aria-hidden="true">route</span>
            <cv-combobox-option value="relay">Relay endpoint</cv-combobox-option>
            <cv-combobox-option value="gateway">Gateway unlock</cv-combobox-option>
            <cv-combobox-option value="import">Credential import</cv-combobox-option>
            <cv-combobox-option value="archive" disabled>Archive export disabled</cv-combobox-option>
          </cv-combobox>
          <span slot="description"
            >Editable mode filters options while keeping the committed selection explicit.</span
          >
        </cv-field>

        <cv-field>
          <span slot="label">Layer tags</span>
          <cv-combobox
            multiple
            clearable
            max-tags-visible="2"
            value="browser otp recovery"
            placeholder="Add tags"
            aria-label="Layer tags"
          >
            <cv-combobox-option value="browser">Browser</cv-combobox-option>
            <cv-combobox-option value="otp">OTP</cv-combobox-option>
            <cv-combobox-option value="recovery">Recovery</cv-combobox-option>
            <cv-combobox-option value="shared">Shared vault</cv-combobox-option>
          </cv-combobox>
          <span slot="description">Multiple selection renders tags and keeps the popup open by default.</span>
        </cv-field>

        <cv-field>
          <span slot="label">Operator</span>
          <cv-combobox type="select-only" value="alex" placeholder="Assign operator" aria-label="Operator">
            <cv-combobox-option value="alex">Alex - security review</cv-combobox-option>
            <cv-combobox-option value="maria">Maria - legal hold</cv-combobox-option>
            <cv-combobox-option value="ops">Ops queue</cv-combobox-option>
          </cv-combobox>
          <span slot="description"
            >Select-only mode follows the combobox trigger pattern without text filtering.</span
          >
        </cv-field>

        <cv-field>
          <span slot="label">Jurisdiction</span>
          <cv-combobox placeholder="Filter jurisdiction" aria-label="Jurisdiction">
            <cv-combobox-group label="Low-friction review">
              <cv-combobox-option value="helsinki">Helsinki</cv-combobox-option>
              <cv-combobox-option value="reykjavik">Reykjavik</cv-combobox-option>
            </cv-combobox-group>
            <cv-combobox-group label="Manual approval">
              <cv-combobox-option value="border">Border crossing</cv-combobox-option>
              <cv-combobox-option value="seizure">Device seizure</cv-combobox-option>
            </cv-combobox-group>
          </cv-combobox>
          <span slot="description"
            >Groups keep navigation flat while preserving labeled listbox structure.</span
          >
        </cv-field>
      </div>
    </form>

    <aside class="combobox-demo-side" aria-label="Combobox event output">
      <div class="combobox-demo-side-head">
        <span class="combobox-demo-kicker">Event stream</span>
        <h4>Interact with any combobox to inspect the public state emitted by the component.</h4>
      </div>

      <p class="combobox-demo-log" role="status" aria-live="polite" data-combobox-output>
        Waiting for interaction. Type, pick an option, toggle a tag, or clear a value.
      </p>

      <dl class="combobox-demo-live" aria-label="Live combobox state">
        <div>
          <dt>Selection</dt>
          <dd data-combobox-selected>relay</dd>
        </div>
        <div>
          <dt>Input</dt>
          <dd data-combobox-input>Relay endpoint</dd>
        </div>
        <div>
          <dt>Popup</dt>
          <dd data-combobox-open>closed</dd>
        </div>
      </dl>

      <div class="combobox-demo-open-card">
        <span>Open grouped listbox</span>
        <cv-combobox
          open
          type="select-only"
          value="work"
          placeholder="Select vault"
          aria-label="Open vault selector"
        >
          <cv-combobox-group label="Vaults">
            <cv-combobox-option value="personal">Personal vault</cv-combobox-option>
            <cv-combobox-option value="work">Work vault</cv-combobox-option>
            <cv-combobox-option value="decoy">Visible decoy vault</cv-combobox-option>
          </cv-combobox-group>
        </cv-combobox>
      </div>
    </aside>
  </section>

  <section class="combobox-demo-section" aria-labelledby="combobox-demo-matrix-title">
    <div class="combobox-demo-section-header">
      <span class="combobox-demo-kicker">State matrix</span>
      <h4 id="combobox-demo-matrix-title">
        One component covers typed filtering, trigger-only selection, tag overflow, invalid input, grouping,
        and sizing.
      </h4>
    </div>

    <div class="combobox-demo-matrix" aria-label="Combobox state matrix">
      <div>
        <span>Editable filter</span>
        <cv-combobox input-value="gate" placeholder="Type to filter" aria-label="Editable filter">
          <cv-combobox-option value="gateway">Gateway</cv-combobox-option>
          <cv-combobox-option value="gatekeeper">Gatekeeper</cv-combobox-option>
          <cv-combobox-option value="relay">Relay</cv-combobox-option>
        </cv-combobox>
      </div>

      <div>
        <span>Select-only</span>
        <cv-combobox type="select-only" value="hardware" placeholder="Choose boundary" aria-label="Boundary">
          <cv-combobox-option value="software">Software only</cv-combobox-option>
          <cv-combobox-option value="hardware">Hardware-assisted</cv-combobox-option>
        </cv-combobox>
      </div>

      <div>
        <span>Tag overflow</span>
        <cv-combobox
          multiple
          clearable
          max-tags-visible="1"
          value="legal source hardware"
          placeholder="Tags"
          aria-label="Tag overflow"
        >
          <cv-combobox-option value="legal">Legal</cv-combobox-option>
          <cv-combobox-option value="source">Source</cv-combobox-option>
          <cv-combobox-option value="hardware">Hardware</cv-combobox-option>
        </cv-combobox>
      </div>

      <div>
        <span>Invalid</span>
        <cv-field invalid>
          <span slot="label">Policy route</span>
          <cv-combobox
            invalid
            input-value="unknown relay"
            placeholder="Search route"
            aria-label="Invalid route"
          >
            <cv-combobox-option value="relay">Relay endpoint</cv-combobox-option>
          </cv-combobox>
          <span slot="error">Route is not available in this visible profile.</span>
        </cv-field>
      </div>

      <div>
        <span>Disabled option</span>
        <cv-combobox value="active" placeholder="Choose state" aria-label="Disabled option">
          <cv-combobox-option value="active">Active</cv-combobox-option>
          <cv-combobox-option value="archived" disabled>Archived policy</cv-combobox-option>
        </cv-combobox>
      </div>

      <div>
        <span>Sizes</span>
        <cv-combobox size="small" value="s" placeholder="Small" aria-label="Small combobox">
          <cv-combobox-option value="s">Small</cv-combobox-option>
          <cv-combobox-option value="m">Medium</cv-combobox-option>
        </cv-combobox>
        <cv-combobox size="large" value="l" placeholder="Large" aria-label="Large combobox">
          <cv-combobox-option value="m">Medium</cv-combobox-option>
          <cv-combobox-option value="l">Large</cv-combobox-option>
        </cv-combobox>
      </div>
    </div>
  </section>
</div>

<script type="module">
  document
    .querySelectorAll('.combobox-demo-shell[data-demo="combobox"]:not([data-ready])')
    .forEach((shell) => {
      shell.dataset.ready = 'true'

      const form = shell.querySelector('[data-combobox-form]')
      const output = shell.querySelector('[data-combobox-output]')
      const selected = shell.querySelector('[data-combobox-selected]')
      const input = shell.querySelector('[data-combobox-input]')
      const popup = shell.querySelector('[data-combobox-open]')

      const eventLabels = {
        'cv-input': 'input',
        'cv-change': 'change',
        'cv-clear': 'clear',
      }

      const getFieldName = (combobox) => {
        const label = combobox.closest('cv-field')?.querySelector('[slot="label"]')?.textContent?.trim()
        return label || combobox.getAttribute('aria-label') || 'combobox'
      }

      const readState = (combobox, detail = {}) => {
        const selectedIds = Array.isArray(detail.selectedIds) ? detail.selectedIds : []
        const value = typeof detail.value === 'string' ? detail.value : combobox.value || ''
        const inputValue =
          typeof detail.inputValue === 'string' ? detail.inputValue : combobox.inputValue || ''
        const open = typeof detail.open === 'boolean' ? detail.open : combobox.open

        return {
          selection: selectedIds.length > 0 ? selectedIds.join(', ') : value || 'none',
          inputValue: inputValue || 'empty',
          popup: open ? 'open' : 'closed',
        }
      }

      const updateState = (combobox, detail = {}) => {
        const state = readState(combobox, detail)

        if (selected) selected.textContent = state.selection
        if (input) input.textContent = state.inputValue
        if (popup) popup.textContent = state.popup

        shell.dataset.comboboxState =
          state.popup === 'open' ? 'open' : state.selection === 'none' ? 'idle' : 'selected'
        return state
      }

      const report = (event) => {
        const combobox = event.target instanceof HTMLElement ? event.target : null
        if (!combobox?.matches('cv-combobox')) return

        const state = updateState(combobox, event.detail)
        const name = getFieldName(combobox)
        const label = eventLabels[event.type] ?? event.type

        if (output) {
          output.textContent = `${label}: ${name} -> ${state.selection}; input ${state.inputValue}; popup ${state.popup}`
        }
      }

      form?.addEventListener('submit', (event) => {
        event.preventDefault()
        if (output) output.textContent = 'submit: form boundary reached without changing combobox state.'
      })

      Object.keys(eventLabels).forEach((eventName) => {
        shell.addEventListener(eventName, report)
      })
    })
</script>

Anatomy

Editable mode (default)

<cv-combobox> (host)
└── <div part="base">
    ├── <div part="input-wrapper">
    │   ├── <div part="tags">                    ← only when [multiple], contains selected tags
    │   │   ├── <span part="tag">                ← one per selected item (up to max-tags-visible)
    │   │   │   ├── <span part="tag-label">
    │   │   │   └── <button part="tag-remove">
    │   │   └── <span part="tag-overflow">       ← "+N more" when overflow
    │   ├── <input part="input" role="combobox">
    │   ├── <button part="clear-button">         ← only when [clearable] and value is present
    │   └── <span part="expand-icon">
    └── <div part="listbox" role="listbox">
        ├── <div part="group" role="group">      ← one per cv-combobox-group
        │   ├── <div part="group-label" role="presentation">
        │   └── <slot>                           ← accepts <cv-combobox-option> within group
        └── <slot>                               ← accepts <cv-combobox-option> (ungrouped)

Select-only mode

<cv-combobox type="select-only"> (host)
└── <div part="base">
    ├── <div part="input-wrapper">
    │   ├── <div part="tags">                    ← only when [multiple]
    │   │   └── (same tag structure as editable)
    │   ├── <div part="trigger" role="combobox"> ← replaces <input> in select-only
    │   │   └── <span part="label">             ← selected value text or placeholder
    │   ├── <button part="clear-button">         ← only when [clearable] and value is present
    │   └── <span part="expand-icon">
    └── <div part="listbox" role="listbox">
        └── (same listbox structure as editable)

Attributes

AttributeTypeDefaultDescription
valueString""Selected option id. In multi mode, space-delimited string of selected option values.
input-valueString""Editable input text. Read-only in select-only mode.
openBooleanfalsePopup open state
typeString"editable"Combobox mode: "editable" | "select-only"
multipleBooleanfalseEnables multi-select behavior
clearableBooleanfalseShows clear button when a value is selected
max-tags-visibleNumber3Maximum tags shown before "+N more" overflow. 0 = unlimited. Only meaningful when multiple is true.
open-on-focusBooleantrueOpens popup when input receives focus
open-on-clickBooleantrueOpens popup on input/trigger click when closed
close-on-selectBooleantrue (single) / false (multi)Closes popup after selection commit. Default depends on multiple.
match-modeString"includes"Default filter mode: includes | startsWith. Ignored in select-only mode.
placeholderString""Placeholder text for input or trigger
disabledBooleanfalsePrevents interaction
sizeString"medium"Size: small | medium | large
aria-labelString""Accessible label for input/listbox

Sizes

Size--cv-combobox-min-height--cv-combobox-padding-inline
small30pxvar(--cv-space-2, 8px)
medium36pxvar(--cv-space-3, 12px)
large42pxvar(--cv-space-4, 16px)

Slots

SlotDescription
(default)One or more <cv-combobox-option> or <cv-combobox-group> children
prefixIcon or element before the input/trigger
suffixIcon or element after the input/trigger (before expand icon)

CSS Parts

PartElementDescription
base<div>Root layout container
input-wrapper<div>Wrapper around input/trigger, tags, clear button, and expand icon
cv-input<input>Editable combobox control (editable mode only)
trigger<div>Button-like trigger control (select-only mode only)
label<span>Selected value text inside trigger (select-only mode)
listbox<div>Popup listbox container
tags<div>Container for selected item tags (multi-select only)
tag<span>Individual selected item tag (multi-select only)
tag-label<span>Text label inside a tag
tag-remove<button>Remove button inside a tag
tag-overflow<span>"+N more" overflow indicator
clear-button<button>Clear selection button (clearable mode only)
expand-icon<span>Dropdown expand/collapse indicator icon
group<div>Option group container inside the listbox
group-label<div>Group header label inside the listbox
prefix<span>Wrapper around the prefix slot
suffix<span>Wrapper around the suffix slot

CSS Custom Properties

PropertyDefaultDescription
--cv-combobox-min-width260pxMinimum inline size of the host
--cv-combobox-min-height36pxMinimum block size of the input/trigger
--cv-combobox-padding-inlinevar(--cv-space-3, 12px)Horizontal padding of the input/trigger
--cv-combobox-max-height220pxMaximum block size of the listbox popup
--cv-combobox-border-colorvar(--cv-color-border, #2a3245)Border color for input/trigger and listbox
--cv-combobox-border-radiusvar(--cv-radius-sm, 6px)Border radius of the input/trigger
--cv-combobox-listbox-radiusvar(--cv-radius-md, 10px)Border radius of the listbox popup
--cv-combobox-gapvar(--cv-space-1, 4px)Gap between base layout sections
--cv-combobox-tag-gapvar(--cv-space-1, 4px)Gap between tags in multi-select
--cv-combobox-tag-radiusvar(--cv-radius-sm, 6px)Border radius of tag chips
--cv-combobox-font-sizeinheritFont size of the input/trigger text

Visual States

Host selectorDescription
:host([disabled])Reduced opacity (0.55), cursor: not-allowed
:host([open])Popup listbox is visible
:host([type="select-only"])Trigger is a button-like element instead of an input
:host([multiple])Multi-select mode with tag chips
:host([clearable])Clear button may be shown
:host([size="small"])Small size overrides
:host([size="large"])Large size overrides

ARIA Contract

Editable mode

  • Input role is combobox
  • Input exposes aria-haspopup="listbox", aria-expanded, aria-controls, aria-autocomplete="list"
  • When popup is open and active option exists, input exposes aria-activedescendant

Select-only mode

  • Trigger role is combobox
  • Trigger exposes aria-haspopup="listbox", aria-expanded, aria-controls
  • aria-autocomplete is not present (no text input)
  • When popup is open and active option exists, trigger exposes aria-activedescendant

Common

  • Popup role is listbox
  • Options use role option
  • When multiple=true, listbox exposes aria-multiselectable="true"
  • Each selected option exposes aria-selected="true" (all selected in multi mode, not just one)
  • Option groups use role="group" with aria-labelledby pointing to the group label element
  • Group label elements use role="presentation"

All ARIA attributes are derived from headless contracts (getInputProps, getListboxProps, getOptionProps, getGroupProps, getGroupLabelProps). UIKit does not compute ARIA state independently.

Events

EventDetailDescription
cv-input{value: string | null, inputValue: string, activeId: string | null, open: boolean, selectedIds: string[]}Fires when combobox interaction changes observable state
cv-change{value: string | null, inputValue: string, activeId: string | null, open: boolean, selectedIds: string[]}Fires when selected option(s) change
cv-clear{}Fires when the clear button is clicked

In multi mode, cv-input fires on each toggle and cv-change fires on each toggle (since every toggle changes selection). The selectedIds array in the detail reflects all currently selected option ids.

Reactive State Mapping

cv-combobox is a visual adapter over headless createCombobox reactive state.

Attribute to Headless (UIKit -> Headless)

UIKit PropertyDirectionHeadless Binding
valueattr -> actionactions.select(id) / actions.clearSelection(). In multi mode, parsed as space-delimited ids.
input-valueattr -> actionactions.setInputValue(value)
openattr -> actionactions.open() / actions.close()
typeattr -> optionpassed as type in createCombobox(options)
multipleattr -> optionpassed as multiple in createCombobox(options)
clearableattr -> optionpassed as clearable in createCombobox(options)
close-on-selectattr -> optionpassed as closeOnSelect in createCombobox(options)
match-modeattr -> optionpassed as matchMode in createCombobox(options)
aria-labelattr -> optionpassed as ariaLabel in createCombobox(options)

Headless to DOM (Headless -> UIKit)

Headless StateDirectionDOM Reflection
state.selectedId()state -> attr[value] host attribute (single mode)
state.selectedIds()state -> attr[value] host attribute as space-delimited string (multi mode)
state.inputValue()state -> attr[input-value] host attribute
state.isOpen()state -> attr[open] host attribute
state.activeId()state -> renderaria-activedescendant on input/trigger
state.hasSelection()state -> renderclear button visibility
state.type()state -> renderdetermines input vs trigger rendering
state.multiple()state -> renderdetermines tag rendering

Contract Spreading

  • contracts.getInputProps() is spread onto [part="input"] (editable) or [part="trigger"] (select-only) -- applies role, aria-haspopup, aria-expanded, aria-controls, aria-autocomplete (editable only), aria-activedescendant, aria-label
  • contracts.getListboxProps() is spread onto [part="listbox"] -- applies role, tabindex, aria-label, aria-multiselectable (multi only)
  • contracts.getOptionProps(id) is spread onto each cv-combobox-option -- applies role, tabindex, aria-selected, aria-disabled, data-active
  • contracts.getGroupProps(groupId) is spread onto each [part="group"] -- applies role, aria-labelledby
  • contracts.getGroupLabelProps(groupId) is spread onto each [part="group-label"] -- applies id, role
  • contracts.getVisibleOptions() drives option/group visibility (supports grouped structure; empty groups are hidden)
  • contracts.getFlatVisibleOptions() available for navigation index calculations

UIKit-Only Concerns (NOT in headless)

  • Tag/chip rendering for multi-select selected items
  • "+N more" overflow display for multi-select (controlled by max-tags-visible)
  • Clear button rendering and visibility (uses state.hasSelection() + clearable attribute)
  • Select-only trigger visual (button-like with selected label + expand icon)
  • Option group visual styling (indentation, group header)
  • Popup positioning and animation
  • cv-clear event dispatch
  • Size variants (small / medium / large)

Behavioral Contract

Editable Mode (default)

  • Text input updates input-value, opens popup, and filters visible options
  • Focus opens popup only when open-on-focus=true
  • Input click opens popup only when open-on-click=true
  • Arrow/Home/End navigation follows headless combobox behavior
  • Enter commits active option (value, input-value, popup closes only when close-on-select=true)
  • Escape closes popup without clearing committed selection
  • Clicking outside closes popup
  • match-mode="startsWith" uses case-insensitive starts-with filtering
  • Slot changes rebuild model while preserving still-valid selected value

Select-Only Mode

  • input-value is not user-editable; setInputValue is a no-op in headless
  • Trigger displays the selected option's label (or placeholder when no selection)
  • Keyboard when closed: Space/Enter opens popup; ArrowDown/ArrowUp opens and activates first/last option
  • Keyboard when open: ArrowDown/ArrowUp navigate; Enter/Space commit active option; Escape closes; Home/End navigate to first/last
  • Type-to-select via printable characters: typeahead jumps to matching option by label prefix
  • Filtering is disabled; all non-disabled options are always visible

Multi-Select

  • commitActive toggles the active option in selectedIds instead of replacing selection
  • select(id) toggles the option instead of replacing
  • Listbox stays open after each selection (default close-on-select=false)
  • input-value is NOT overwritten on commit (it drives filtering in editable multi mode)
  • In select-only multi mode, inputValue is always "" (trigger shows tags instead)
  • Tags/chips are rendered inside [part="tags"] for each selected item
  • When selectedIds.length > max-tags-visible, overflow shows "+N more" in [part="tag-overflow"]
  • Clicking [part="tag-remove"] calls actions.removeSelected(id)
  • value attribute reflects all selected ids as a space-delimited string

Clearable

  • Clear button [part="clear-button"] is visible when clearable=true and state.hasSelection() is true
  • Clicking the clear button calls actions.clear() (resets both selection and input value)
  • cv-clear event is dispatched when the clear button is clicked

Option Groups

  • <cv-combobox-group label="Name"> wraps <cv-combobox-option> children into a visual group
  • Groups are rendered as [part="group"] with role="group" and aria-labelledby pointing to [part="group-label"]
  • Groups with all options filtered out are hidden
  • Navigation crosses group boundaries seamlessly (headless handles this via flat visible options)

Disabled State

  • When disabled=true, the combobox is non-interactive: input/trigger cannot be focused, popup cannot open, clear/tag-remove buttons are inert

Optional Advanced Behaviors (Future Scope)

These behaviors are optional and currently not required on cv-combobox:

  • free-text/custom value commit when no option is active
  • async option loading
  • inline autocomplete completion rendering

Child Elements

cv-combobox-option

Individual option within a combobox. The parent cv-combobox manages all ARIA attributes on this element via headless contracts.

Anatomy

<cv-combobox-option> (host)
└── <div part="base">
    └── <slot>

Attributes

AttributeTypeDefaultDescription
valueString""Unique identifier for this option. Auto-generated as option-{n} if omitted.
disabledBooleanfalseWhether the option is disabled
selectedBooleanfalseWhether the option is selected. Managed by parent.
activeBooleanfalseWhether the option is the active (highlighted) option. Managed by parent.

Slots

SlotDescription
(default)Option label content

CSS Parts

PartElementDescription
base<div>Root wrapper for the option content

Visual States

Host selectorDescription
:host([selected])Option is currently selected
:host([active]) / :host([data-active="true"])Option is the active (highlighted) option
:host([disabled])Option is disabled
:host([hidden])Option is filtered out or popup is closed

cv-combobox-group

Groups related options under a labeled header. Must be a direct child of cv-combobox.

Anatomy

<cv-combobox-group> (host)
└── <slot>           ← accepts <cv-combobox-option> children

Attributes

AttributeTypeDefaultDescription
labelString""Visible group header text. Also used for aria-labelledby linkage via headless getGroupLabelProps.

Slots

SlotDescription
(default)One or more <cv-combobox-option> children

Visual States

Host selectorDescription
:host([hidden])All options in this group are filtered out

ChromVoid UIKit documentation