Skip to content

cv-select

Single or multi-selection dropdown that composes a combobox trigger with a listbox popup, following the W3C APG Select-Only Combobox pattern.

Headless: createSelect

Usage

View source
html
<div class="select-demo-shell" data-demo="select" data-live-demo-height="820">
  <header class="select-demo-hero">
    <div class="select-demo-copy">
      <span class="select-demo-kicker">Select-only combobox</span>
      <h3>Route a vault policy with grouped, multiple, clearable, and invalid states.</h3>
      <p>
        The demo keeps DOM focus on the trigger, mirrors selection through
        <code>cv-input</code> and <code>cv-change</code>, and shows the control inside field labels,
        descriptions, and errors.
      </p>
    </div>

    <dl class="select-demo-metrics" aria-label="Select contract coverage">
      <div>
        <dt>Pattern</dt>
        <dd>APG select-only combobox</dd>
      </div>
      <div>
        <dt>Modes</dt>
        <dd>Single + multiple</dd>
      </div>
      <div>
        <dt>State</dt>
        <dd>Open, disabled, invalid, clearable</dd>
      </div>
    </dl>
  </header>

  <section class="select-demo-board" aria-label="Vault policy form">
    <div class="select-demo-panel select-demo-panel--primary">
      <div class="select-demo-section-header">
        <span class="select-demo-kicker">Primary decision</span>
        <h4>Choose the trust boundary before exposing any downstream route.</h4>
      </div>

      <cv-field class="select-demo-field select-demo-field--wide">
        <span slot="label">Trust boundary</span>
        <span slot="description">Grouped options demonstrate section labels inside the popup.</span>
        <cv-select data-select-demo-primary clearable value="hardware" placeholder="Choose boundary">
          <cv-select-group label="Local">
            <cv-select-option value="hardware">Hardware core</cv-select-option>
            <cv-select-option value="device">Device-bound vault</cv-select-option>
          </cv-select-group>
          <cv-select-group label="Deniable">
            <cv-select-option value="visible">Visible layer</cv-select-option>
            <cv-select-option value="hidden">Hidden layer</cv-select-option>
          </cv-select-group>
        </cv-select>
      </cv-field>

      <div class="select-demo-field-grid">
        <cv-field class="select-demo-field" required invalid>
          <span slot="label">Review gate</span>
          <span slot="description">Required + invalid state is delegated by <code>cv-field</code>.</span>
          <span slot="error">Select an approval path before release.</span>
          <cv-select placeholder="Required choice">
            <cv-select-option value="owner">Owner approval</cv-select-option>
            <cv-select-option value="quorum">Quorum approval</cv-select-option>
          </cv-select>
        </cv-field>

        <cv-field class="select-demo-field">
          <span slot="label">Session TTL</span>
          <span slot="description">Small size keeps dense forms compact.</span>
          <cv-select size="small" value="15m" placeholder="TTL">
            <cv-select-option value="5m">5 minutes</cv-select-option>
            <cv-select-option value="15m">15 minutes</cv-select-option>
            <cv-select-option value="1h">1 hour</cv-select-option>
          </cv-select>
        </cv-field>
      </div>
    </div>

    <aside class="select-demo-side" aria-label="Live select state">
      <span class="select-demo-kicker">Event readout</span>
      <dl class="select-demo-state">
        <div>
          <dt>Values</dt>
          <dd data-select-demo-values>hardware</dd>
        </div>
        <div>
          <dt>Active option</dt>
          <dd data-select-demo-active>none</dd>
        </div>
      </dl>
      <output class="select-demo-log" data-select-demo-output> ready -> Trust boundary: hardware </output>
    </aside>
  </section>

  <section class="select-demo-cases" aria-label="Select state matrix">
    <div class="select-demo-section-header">
      <span class="select-demo-kicker">State matrix</span>
      <h4>Common application cases stay visible without turning the demo into a control dump.</h4>
    </div>

    <div class="select-demo-case-grid">
      <cv-field class="select-demo-field">
        <span slot="label">Multiple tags</span>
        <span slot="description">Selected options are reflected through <code>selectedValues</code>.</span>
        <cv-select selection-mode="multiple" clearable placeholder="Select tags">
          <cv-select-option value="a11y" selected>Accessibility</cv-select-option>
          <cv-select-option value="forms" selected>Forms</cv-select-option>
          <cv-select-option value="keyboard">Keyboard</cv-select-option>
          <cv-select-option value="testing">Testing</cv-select-option>
        </cv-select>
      </cv-field>

      <cv-field class="select-demo-field">
        <span slot="label">Persistent popup</span>
        <span slot="description">Multiple mode can keep the listbox open after selection.</span>
        <cv-select data-select-demo-keep-open selection-mode="multiple" placeholder="Pick checks">
          <cv-select-option value="aria">ARIA contract</cv-select-option>
          <cv-select-option value="events">Event payload</cv-select-option>
          <cv-select-option value="form">Form value</cv-select-option>
        </cv-select>
      </cv-field>

      <cv-field class="select-demo-field" disabled>
        <span slot="label">Disabled field</span>
        <span slot="description">The parent field forwards disabled state to the select.</span>
        <cv-select value="locked" placeholder="Cannot interact">
          <cv-select-option value="locked">Locked by policy</cv-select-option>
        </cv-select>
      </cv-field>

      <cv-field class="select-demo-field">
        <span slot="label">Disabled option</span>
        <span slot="description">Unavailable choices stay visible but cannot be selected.</span>
        <cv-select value="active" placeholder="Choose status">
          <cv-select-option value="active">Active route</cv-select-option>
          <cv-select-option value="archived" disabled>Archived route</cv-select-option>
          <cv-select-option value="pending">Pending review</cv-select-option>
        </cv-select>
      </cv-field>
    </div>
  </section>
</div>

<script type="module">
  const formatSelectValues = (select) => {
    const values = select.selectedValues?.length ? select.selectedValues : select.value ? [select.value] : []

    return values.length > 0 ? values.join(', ') : 'none'
  }

  document.querySelectorAll('.select-demo-shell[data-demo="select"]:not([data-ready])').forEach((shell) => {
    shell.dataset.ready = 'true'

    const output = shell.querySelector('[data-select-demo-output]')
    const values = shell.querySelector('[data-select-demo-values]')
    const active = shell.querySelector('[data-select-demo-active]')
    const primary = shell.querySelector('[data-select-demo-primary]')

    const emitState = (select, type, detail = {}) => {
      const label =
        select.closest('cv-field')?.querySelector('[slot="label"]')?.textContent?.trim() || 'Select'
      const selected = formatSelectValues(select)

      if (values) values.textContent = selected
      if (active) active.textContent = detail.activeId || 'none'
      if (output) {
        output.textContent = `${type} -> ${label}: ${selected} (${detail.open ? 'open' : 'closed'})`
      }
    }

    shell.querySelectorAll('[data-select-demo-keep-open]').forEach((select) => {
      select.closeOnSelect = false
    })

    shell.querySelectorAll('cv-select').forEach((select) => {
      select.addEventListener('cv-input', (event) => emitState(select, event.type, event.detail))
      select.addEventListener('cv-change', (event) => emitState(select, event.type, event.detail))
    })

    if (primary) {
      requestAnimationFrame(() => emitState(primary, 'ready', {activeId: null, open: primary.open}))
    }
  })
</script>

Anatomy

<cv-select> (host)
└── <div part="base">
    ├── <div part="trigger" role="combobox">
    │   ├── <span part="value">
    │   │   └── <slot name="trigger"> ← fallback: selected label / placeholder
    │   ├── <button part="clear-button" aria-hidden="true"> ← only when clearable + has value
    │   └── <span part="chevron" aria-hidden="true">
    └── <div part="listbox" role="listbox">
        └── <slot> ← cv-select-option / cv-select-group children

Attributes

AttributeTypeDefaultDescription
valueString""Currently selected option value (single-select)
openBooleanfalseWhether the listbox popup is visible
selection-modeString"single"Selection mode: single | multiple
aria-labelString""Accessible label for the trigger
aria-labelledbyString""External label id, typically from cv-field
aria-describedbyString""External description id, typically from cv-field
close-on-selectBooleantrueClose popup after an option is selected
placeholderString""Hint text when no option is selected
disabledBooleanfalsePrevents all interaction
requiredBooleanfalseMarks the field as required for form validation
clearableBooleanfalseShows a clear button when a value is selected
sizeString"medium"Size: small | medium | large

Non-reflected properties:

PropertyTypeDefaultDescription
selectedValuesstring[][]Array of selected option values (useful in multiple mode)

Sizes

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

Slots

SlotDescription
(default)cv-select-option and cv-select-group children
triggerCustom trigger content (replaces default selected label text)

CSS Parts

PartElementDescription
base<div>Root layout wrapper
trigger<div>Combobox trigger that opens/closes the listbox
value<span>Selected label / placeholder wrapper
chevron<span>Dropdown arrow indicator
clear-button<button>Clear value button (only rendered when clearable and value is set)
listbox<div>Popup container holding options

CSS Custom Properties

PropertyDefaultDescription
--cv-select-inline-size260pxInline size of the host element
--cv-select-min-height36pxMinimum block size of the trigger
--cv-select-padding-inlinevar(--cv-space-3, 12px)Horizontal padding of the trigger
--cv-select-padding-blockvar(--cv-space-2, 8px)Vertical padding of the trigger
--cv-select-clear-button-size1.4emInline and block size of the clear button

Visual States

Host selectorDescription
:host([open])Listbox popup is visible and the chevron rotates upward
:host([selection-mode="multiple"])Multiple selection mode active
:host([disabled])Reduced opacity, cursor: not-allowed, all interaction blocked
:host([required])Field is required
:host([clearable])Clear button visible when value is set
:host([size="small"])Small size overrides
:host([size="large"])Large size overrides

Events

EventDetailDescription
cv-input{value: string | null, values: string[], activeId: string | null, open: boolean}Fires on any state change (selection, active, open)
cv-change{value: string | null, values: string[], activeId: string | null, open: boolean}Fires only when selected value(s) change

Keyboard Interaction

Trigger focused (listbox closed)

KeyAction
ArrowDown / HomeOpen listbox and focus first option
ArrowUp / EndOpen listbox and focus last option
Enter / SpaceToggle listbox open/close

Listbox open (DOM focus remains on trigger)

KeyAction
ArrowDownMove visual focus to next option
ArrowUpMove visual focus to previous option
HomeMove visual focus to first option
EndMove visual focus to last option
Enter / SpaceSelect active option (close if close-on-select)
Escape / TabClose listbox without changing selection

When disabled

All keyboard handlers are no-ops.

ARIA Contract

ElementAttributeValue
triggerrolecombobox
triggertabindex0
triggeraria-haspopuplistbox
triggeraria-expandedtrue / false
triggeraria-controlslistbox element id
triggeraria-activedescendantid of visually focused option (when open)
triggeraria-disabledtrue (when disabled)
triggeraria-requiredtrue (when required)
triggeraria-labelaccessible label text
listboxrolelistbox
listboxaria-activedescendantid of focused option
listboxaria-multiselectabletrue (when selection-mode="multiple")
optionroleoption
optionaria-selectedtrue / false
optionaria-disabledtrue (when disabled)

Reactive State Mapping

UIKit PropertyDirectionHeadless Binding
valueactions.select(id) on change
disabledactions.setDisabled(value)
requiredactions.setRequired(value)
openstate.isOpen()
selectedValuesstate.selectedIds()
trigger ARIAcontracts.getTriggerProps() spread
listbox ARIAcontracts.getListboxProps() spread
option ARIAcontracts.getOptionProps(id) spread
trigger labelcontracts.getValueText()

Child Elements

cv-select-option

Selectable item within a cv-select or cv-select-group.

Anatomy

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

Attributes

AttributeTypeDefaultDescription
valueString""Option value submitted to the parent select
disabledBooleanfalsePrevents selection
selectedBooleanfalseReflects selected state (managed by parent)
activeBooleanfalseReflects active/focused state (managed by parent)

Slots

SlotDescription
(default)Option label text

CSS Parts

PartElementDescription
base<div>Option root wrapper

Visual States

Host selectorDescription
:host([active])Option has keyboard focus (primary tint at 24%)
:host([selected])Option is selected (primary tint at 32%)
:host([disabled])Option is non-selectable (opacity 0.5)
:host([hidden])Option is hidden when listbox is closed

cv-select-group

Groups related options under a visible label.

Anatomy

<cv-select-group> (host)
├── <div part="label" class="label"> ← group label text
└── <slot> ← cv-select-option children

Attributes

AttributeTypeDefaultDescription
labelString""Group label text

Slots

SlotDescription
(default)cv-select-option children

CSS Parts

PartElementDescription
label<div>Group label text element

Visual States

Host selectorDescription
:host([hidden])Group is hidden when listbox is closed

ChromVoid UIKit documentation