Listbox
<cui-listbox>
Live Example
Selected: {{ selected || '(none)' }}
Documentation
cui-listbox
An accessible, keyboard-navigable listbox component for Vue 3 applications. Built on native <ul>/<li> semantics with full WAI-ARIA listbox/option role support, form integration via a hidden input, and built-in validation error display.
Features
- Accessible: Implements
role="listbox"/role="option"witharia-activedescendant,aria-selected,aria-labelledby, andaria-invalid - Keyboard Navigation: Arrow keys, Home, End, Enter, Space, and Escape
- Form Integration: Hidden
<input>carries the selected value for HTML form submission - Validation: Built-in error display slot and integration with
ComponentValErrorFns - Flexible Items: Supports plain arrays, arrays of objects, custom
valueField/textField - Disabled / Readonly: Full support via props and CSS
- Wraparound Navigation: Arrow keys wrap from last to first item and vice versa
- Custom Slot: Default slot allows fully custom
<li>markup instead of auto-generated items
Props
| Prop | Type | Default | Required | Description |
|---|---|---|---|---|
| id | String | null |
No | ID for the hidden input. Auto-generated if omitted |
| label | String | — | No | Visible label rendered above the listbox |
| modelValue | any | — | No | Currently selected value (v-model) |
| listItems | Array | — | No | Array of items to render. May be primitives or objects |
| valueField | String | — | No | Property name to use as the item value when items are objects |
| textField | String | — | No | Property name to use as the display text when items are objects |
| itemElementType | String | 'li' |
No | Element type used for keyboard navigation matching |
| required | Boolean | false |
No | Marks the field as required for validation |
| disabled | Boolean | false |
No | Disables the listbox and prevents selection |
| readonly | Boolean | false |
No | Prevents selection changes without disabling focus |
| errorMsg | String | — | No | Validation error message displayed below the listbox |
Events
| Event | Payload | Description |
|---|---|---|
| update:model-value | any |
Emitted when a new item is selected (v-model) |
| change | Event | Emitted when the selected value changes from its original value |
| input | Event | Emitted on every value set |
| focus | Event | Emitted when the listbox or an option receives focus |
| blur | Event | Emitted when focus leaves the listbox entirely |
| error | string | Emitted when a validation error is set |
| clear-error | — | Emitted when a validation error is cleared |
Slots
Default slot
Replaces the auto-generated <li> items. Use this for fully custom item markup.
<cui-listbox v-model="selected" :listItems="null">
<li role="option" data-value="1" @click="selected = 1">Custom Item One</li>
<li role="option" data-value="2" @click="selected = 2">Custom Item Two</li>
</cui-listbox>
errors slot
Override the default error message display. Receives the following slot props:
| Slot Prop | Description |
|---|---|
| id | Component ID |
| value | Last emitted value |
| item | Last emitted item object |
| required | Required prop value |
| disabled | Disabled prop value |
| readonly | Readonly prop value |
| validateTrigger | Increments each time validation should be re-evaluated |
<cui-listbox v-model="selected" :listItems="items">
<template #errors="{ value, validateTrigger }">
<span v-if="!value && validateTrigger > 0" class="error">
Please select an item.
</span>
</template>
</cui-listbox>
Keyboard Navigation
Follows the WAI-ARIA Listbox Pattern:
| Key | Action |
|---|---|
| Arrow Down | Move focus to next option (wraps to first) |
| Arrow Up | Move focus to previous option (wraps to last) |
| Home | Move focus to first option |
| End | Move focus to last option |
| Enter / Space | Select the focused option |
| Escape | Return focus to the listbox container |
Accessibility
| ARIA Attribute | Applied To | Value |
|---|---|---|
role="listbox" |
<ul> |
Identifies the container as a listbox widget |
role="option" |
<li> |
Identifies each item as a selectable option |
aria-activedescendant |
<ul> |
ID of the currently focused option |
aria-labelledby |
<ul> |
ID of the label <span> |
aria-invalid |
<ul> |
true when errorMsg is non-empty |
aria-selected |
<li> |
true for the currently selected option |
NVDA / screen reader note: The
role="listbox"+role="option"combination causes NVDA to automatically enter application mode, passing arrow keys directly to the component'sonKeydownhandler rather than using NVDA's own virtual cursor.
Basic Usage
Plain array
<cui-listbox
:listItems="['Apple', 'Banana', 'Cherry']"
v-model="selectedFruit"
label="Choose a fruit" />
Array of objects
<cui-listbox
:listItems="countries"
value-field="code"
text-field="name"
v-model="selectedCountry"
label="Country" />
With validation error
<cui-listbox
:listItems="items"
value-field="id"
text-field="name"
v-model="selectedId"
:required="true"
:errorMsg="validationError"
label="Required field" />
Disabled items
Individual options can be disabled by adding a disabled attribute or disabled class to <li> elements in the default slot:
<cui-listbox v-model="selected">
<li role="option" @click="selected = 1">Enabled</li>
<li role="option" disabled>Disabled</li>
<li role="option" class="disabled">Also Disabled</li>
</cui-listbox>
Complete Example
<template>
<div>
<cui-listbox
id="roleList"
label="User Role"
:listItems="roles"
value-field="id"
text-field="name"
v-model="selectedRoleId"
:required="true"
:errorMsg="roleError"
@change="onRoleChange"
@blur="validateRole">
<template #errors="{ value, validateTrigger }">
<span v-if="!value && validateTrigger > 0" class="field-error">
A role is required.
</span>
</template>
</cui-listbox>
<p>Selected role ID: {{ selectedRoleId }}</p>
</div>
</template>
<script>
export default {
data() {
return {
selectedRoleId: null,
roleError: null,
roles: [
{ id: 1, name: 'Administrator' },
{ id: 2, name: 'Editor' },
{ id: 3, name: 'Viewer' },
]
};
},
methods: {
onRoleChange() {
this.roleError = null;
},
validateRole() {
this.roleError = this.selectedRoleId == null
? 'A role is required.'
: null;
}
}
};
</script>
Styling
/* Listbox container */
.cui-listbox ul {
border: 1px solid #ccc;
border-radius: 4px;
list-style-type: none;
margin: 0;
padding: 0;
}
/* Options */
.cui-listbox [role="option"] {
padding: 8px 16px;
cursor: pointer;
white-space: nowrap;
outline: none;
}
/* Hover, focus, selected states */
.cui-listbox [role="option"]:hover,
.cui-listbox [role="option"]:focus,
.cui-listbox [role="option"].selected {
background: #f0f0f0;
}
/* Disabled options */
.cui-listbox [role="option"][disabled],
.cui-listbox [role="option"].disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* Disabled/readonly container */
.cui-listbox.disabled,
.cui-listbox.readonly {
opacity: 0.6;
pointer-events: none;
}
Common Issues
Arrow keys not working with NVDA
Ensure items use role="option" (not role="listitem"). NVDA only enters application mode — which passes arrow keys to the component — when it detects a role="listbox" container with role="option" children.
Selected value not submitted with form
Ensure the name prop or the auto-generated Name is set. The component renders a hidden <input> that carries the selected value.