cv-listbox
Standalone listbox widget for single or multiple selection from a list of options, with keyboard navigation, typeahead, optional grouping, and virtual scroll support.
Headless: createListbox
Usage
The demo keeps the headless contract visible: active focus, selected values, group labels, range selection, and alternate focus/orientation modes are shown in one operational surface. For option label composition, prefix/suffix slots, and rich option content, see cv-option.
Anatomy
<cv-listbox> (host)
└── <div part="base" role="listbox">
└── <slot> ← accepts <cv-option> and <cv-listbox-group> childrenAttributes
| Attribute | Type | Default | Description |
|---|---|---|---|
selection-mode | String | "single" | Selection mode: "single" | "multiple" |
orientation | String | "vertical" | Layout orientation: "vertical" | "horizontal" |
focus-strategy | String | "aria-activedescendant" | Focus management: "aria-activedescendant" | "roving-tabindex" |
selection-follows-focus | Boolean | false | Auto-select focused option in single mode |
range-selection | Boolean | false | Enable Shift+Arrow and Shift+Space range selection (multiple mode only) |
typeahead | Boolean | true | Enable typeahead character navigation |
aria-label | String | "" | Accessible label for the listbox |
Non-reflected properties:
| Property | Type | Default | Description |
|---|---|---|---|
value | string | null | null | First selected option value (single-select shorthand) |
selectedValues | string[] | [] | Array of all selected option values |
Slots
| Slot | Description |
|---|---|
(default) | One or more <cv-option> or <cv-listbox-group> children |
CSS Parts
| Part | Element | Description |
|---|---|---|
base | <div> | Root listbox element with role="listbox" |
CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--cv-listbox-gap | var(--cv-space-1, 4px) | Gap between options |
--cv-listbox-padding | var(--cv-space-1, 4px) | Inner padding of the listbox container |
--cv-listbox-border-radius | var(--cv-radius-md, 10px) | Border radius of the listbox container |
--cv-listbox-border-color | var(--cv-color-border, #2a3245) | Border color |
--cv-listbox-background | var(--cv-color-surface, #141923) | Background color |
--cv-listbox-focus-outline-color | var(--cv-color-primary, #65d7ff) | Focus-visible outline color |
Visual States
| Host selector | Description |
|---|---|
:host([orientation="horizontal"]) | Horizontal layout (flexbox row direction) |
:host([selection-mode="multiple"]) | Multiple selection mode active |
:host([focus-strategy="roving-tabindex"]) | Options receive DOM focus directly |
Events
| Event | Detail | Description |
|---|---|---|
cv-input | {selectedValues: string[], activeValue: string | null} | Fires when active option or selection changes via user interaction |
cv-change | {selectedValues: string[], activeValue: string | null} | Fires when selected option(s) change via user interaction |
Keyboard Interaction
All keyboard handling is delegated to headless actions.handleKeyDown. The following is the resulting behavior:
| Key | Context | Action |
|---|---|---|
ArrowDown / ArrowRight* | any | Move to next enabled option |
ArrowUp / ArrowLeft* | any | Move to previous enabled option |
Home | any | Move to first enabled option |
End | any | Move to last enabled option |
Space / Enter | single mode | Select active option exclusively |
Space / Enter | multiple mode | Toggle active option selection |
Escape | any | Close (for composite patterns) |
Ctrl/Cmd + A | multiple mode | Select all enabled options |
Shift + Arrow | multiple + range-selection | Extend range selection |
Shift + Space | multiple + range-selection | Select range from anchor to active |
| printable char | typeahead enabled | Typeahead navigation to matching option |
*Arrow key mapping depends on orientation: vertical uses Up/Down, horizontal uses Left/Right.
Reactive State Mapping
cv-listbox is a visual adapter over headless createListbox.
Attribute to Headless (UIKit -> Headless)
| UIKit Property | Direction | Headless Binding |
|---|---|---|
selection-mode | attr -> option | passed as selectionMode in createListbox(options) |
orientation | attr -> option | passed as orientation in createListbox(options) |
focus-strategy | attr -> option | passed as focusStrategy in createListbox(options) |
selection-follows-focus | attr -> option | passed as selectionFollowsFocus in createListbox(options) |
range-selection | attr -> option | passed as rangeSelection in createListbox(options) |
typeahead | attr -> option | passed as typeahead in createListbox(options) |
aria-label | attr -> option | passed as ariaLabel in createListbox(options) |
value (setter) | prop -> action | actions.selectOnly(id) / actions.clearSelected() |
When any configuration attribute changes, the headless model is rebuilt via createListbox with updated options, preserving current selection and active state where still valid.
Headless to DOM (Headless -> UIKit)
| Headless State | Direction | DOM Reflection |
|---|---|---|
state.activeId() | state -> render | aria-activedescendant on [part="base"] (activedescendant strategy); DOM focus on active option (roving-tabindex strategy) |
state.selectedIds() | state -> render | [aria-selected] on each cv-option; selectedValues property; value property |
state.selectionMode | state -> attr | [selection-mode] host attribute |
state.focusStrategy | state -> attr | [focus-strategy] host attribute |
state.orientation | state -> attr | [orientation] host attribute |
state.optionCount | state -> render | aria-setsize on each option via getOptionProps |
Contract Spreading
contracts.getRootProps()is spread onto[part="base"]-- appliesrole,tabindex,aria-orientation,aria-label,aria-multiselectable,aria-activedescendantcontracts.getOptionProps(id)is spread onto eachcv-option-- appliesid,role,tabindex,aria-selected,aria-disabled,aria-setsize,aria-posinset,data-activecontracts.getGroupProps(groupId)is spread onto eachcv-listbox-groupshadow root group container -- appliesid,role,aria-labelledbycontracts.getGroupLabelProps(groupId)is spread onto each group label element -- appliesid,rolecontracts.getGroupOptions(groupId)drives which options render within a groupcontracts.getUngroupedOptions()drives which options render outside any group
UIKit-Only Concerns (NOT in headless)
- Option visual styling (active highlight, selected highlight, disabled opacity)
- Group visual styling (label header, indentation)
- Virtual scroll viewport management and option recycling
cv-inputandcv-changeevent dispatch based on state diffing after user interactionspreventDefaulton navigation keys to prevent page scroll- Slot change detection to rebuild the headless model when child options are added/removed
Behavioral Contract
Option Collection
cv-listboxscans its light DOM children (directcv-optionandcv-optionwithincv-listbox-group) to build the options array for the headless model- Each
cv-optionmust have avalueattribute; if omitted, an auto-generated fallbackoption-{n}is assigned - The
textContentof eachcv-optionis used as the option label for typeahead matching - Initial selection is read from
cv-option[selected]attributes at first render - On
slotchange, the model is rebuilt with the updated option list, preserving still-valid selection and active state
Pointer Interaction
- Clicking a
cv-optioncallsactions.setActive(id)followed byactions.selectOnly(id)(single) oractions.toggleSelected(id)(multiple) - Pointer interactions dispatch
cv-inputandcv-changeevents based on state diffing
Focus Management
- When
focus-strategy="aria-activedescendant"(default):[part="base"]hastabindex="0"and receives DOM focus;aria-activedescendantpoints to the active option; all options havetabindex="-1" - When
focus-strategy="roving-tabindex":[part="base"]hastabindex="-1"; the active option hastabindex="0"and receives DOM focus; other options havetabindex="-1"
Virtual Scroll Support
aria-setsizeandaria-posinsetfromgetOptionPropssupport virtual scrolling- When using virtual scrolling, only a subset of options is rendered, but each carries correct setsize/posinset reflecting the full option list
- Virtual scroll viewport management is a UIKit concern, not headless
Child Elements
cv-option
Individual selectable option within a cv-listbox or cv-listbox-group. The parent cv-listbox manages all ARIA attributes on this element via headless contracts.
Anatomy
<cv-option> (host)
└── <div part="base">
└── <slot>Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
value | String | "" | Unique identifier for this option. Auto-generated as option-{n} if omitted. |
disabled | Boolean | false | Whether the option is disabled |
selected | Boolean | false | Whether the option is selected. Managed by parent. |
active | Boolean | false | Whether the option is the active (highlighted) option. Managed by parent. |
Slots
| Slot | Description |
|---|---|
(default) | Option label content |
CSS Parts
| Part | Element | Description |
|---|---|---|
base | <div> | Root wrapper for the option content |
Visual States
| Host selector | Description |
|---|---|
:host([selected]) | Option is currently selected (primary tint at 34%) |
:host([active]) | Option is the active/highlighted option (primary tint at 22%) |
:host([disabled]) | Option is disabled (opacity 0.55) |
:host(:focus-visible) | Focus ring when option receives DOM focus (roving-tabindex strategy) |
cv-listbox-group
Groups related options under a visible label header. Must be a direct child of cv-listbox.
Anatomy
<cv-listbox-group> (host)
├── <div part="label"> ← group label text
└── <slot> ← accepts <cv-option> childrenAttributes
| Attribute | Type | Default | Description |
|---|---|---|---|
label | String | "" | Visible group header text. Also used for aria-labelledby linkage via headless getGroupLabelProps. |
Slots
| Slot | Description |
|---|---|
(default) | One or more <cv-option> children |
CSS Parts
| Part | Element | Description |
|---|---|---|
label | <div> | Group label text element |
CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--cv-listbox-group-label-color | var(--cv-color-text-muted, #8892a6) | Group label text color |
--cv-listbox-group-label-font-size | 0.85em | Group label font size |
--cv-listbox-group-gap | var(--cv-space-1, 4px) | Gap between group label and options |
Visual States
| Host selector | Description |
|---|---|
:host | Block display with group role and aria-labelledby |