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
--cv-select-backgroundvar(--cv-color-surface)Background of the trigger
--cv-select-border-radiusvar(--cv-radius-sm, 6px)Border radius of the trigger
--cv-select-listbox-max-block-size240pxPreferred maximum block size of the listbox popup
--cv-select-listbox-min-fit-block-size12remFit-content minimum used by supported calc-size() sizing
--cv-select-listbox-viewport-block-gutter32pxReserved viewport block space before popup scrolling

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
value→actions.select(id) on change
disabled→actions.setDisabled(value)
required→actions.setRequired(value)
open←state.isOpen()
selectedValues←state.selectedIds()
trigger ARIA←contracts.getTriggerProps() spread
listbox ARIA←contracts.getListboxProps() spread
option ARIA←contracts.getOptionProps(id) spread
trigger label←contracts.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