Component
Vue Select Component
<DomSelect>A styled select control with a closed trigger, floating listbox, keyboard navigation, and rich option slots.
Playground
Try every prop live
Select playground
Use DomSelect instead of DomNativeSelect when options need custom rows, metadata, status, or previews.
Properties
Control props
Selected option value.
Text shown when no option is selected.
Available options. Set group for non-selectable section headings and use the option slot for rich rows.
Preferred side before collision handling.
Horizontal panel alignment.
viewport keeps the list inside the browser; anchor keeps it attached while scrolling.
Tailwind width utility for the floating listbox.
Theme token basename for hovered, focused, and selected options. For example, primary uses var(--primary) and var(--primary-fg).
Placeholder shown inside the panel search input.
Field props
Optional ID override. By default parent forms derive the input ID from the field path using underscores.
Local field name. Parent forms derive the full field path and native HTML name from the form hierarchy.
Visible field label.
Optional helper copy below the field.
Validation errors for this field.
Validators
No validators yet.
Validators attached to this field. Use functions in Vue code, or serializable records such as { name: "minLength", props: { min: 2 } } in generated schemas.
Render default field chrome, or hide chrome while keeping form state wiring.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { reactive } from 'vue';
import { DomSelect } from '@getdom/studio';
const data = reactive({
"modelValue": "pro",
"id": "",
"name": "",
"label": "Plan",
"description": "",
"placeholder": "Select an option",
"required": false,
"disabled": false,
"readOnly": false,
"invalid": false,
"errors": [],
"visible": true,
"validators": [],
"validateOnBlur": true,
"chrome": "field",
"options": [
{
"label": "Starter",
"value": "starter",
"description": "For small projects."
},
{
"label": "Pro",
"value": "pro",
"description": "For growing teams."
},
{
"label": "Enterprise",
"value": "enterprise",
"description": "For regulated work."
}
],
"placement": "bottom",
"align": "left",
"floatingMode": "viewport",
"width": "min-w-[14rem]",
"optionTone": "accent",
"searchable": false,
"searchPlaceholder": "Search options...",
"multiple": false,
"filterOptions": true,
"loading": false,
"hasMore": false,
"selectedOptions": [],
"clearable": false
});
</script>
<template>
<DomSelect
v-bind="data"
@update:modelValue="data.modelValue = $event"
/>
</template>Choosing a control
Multi-select or tag input?
Use Select with multiple for a compact picker of known options, such as reviewers, roles or projects. The closed field summarizes the selection; search lives inside the dropdown, where checkmarks let people select or deselect several items. It does not create new values.
Use Tag combobox (tag input) when each selected value should remain visible as a removable token, with typing and suggestions in the field itself. It suits recipients, labels and class names. Keep allowCustom off for known options only, or enable it for new values. Pasting can add several tokens using tokenSeparators; Backspace removes the last token when the query is empty.
Both use the same option normalization, selected-record retention, field state, pagination requests and loading/empty-state components. Both retain selections while searching or loading server pages. The different trigger, token editing and keyboard interactions are intentional; applications still own their data fetching.
Demo
Workspace switcher
The searchable panel opens to the full option list, then filters rich rows as the user types while v-model stores a simple value.
Use DomSelect when options need richer markup than a native select can render.
Selected value: growth
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, ref } from 'vue';
import { DomAvatar, DomBadge, DomSelect, DomStatusPill } from '@getdom/studio';
const workspace = ref('growth');
const workspaces = [
{
value: 'growth',
label: 'Growth workspace',
description: 'Campaigns, experiments, and referral programs.',
initials: 'GW',
src: 'https://images.unsplash.com/photo-1527980965255-d3b416303d12?auto=format&fit=crop&crop=faces&w=96&h=96&q=80',
tone: 'success',
plan: 'Scale',
count: '18 members',
},
{
value: 'platform',
label: 'Platform team',
description: 'API keys, webhooks, usage limits, and internal tools.',
initials: 'PT',
src: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&crop=faces&w=96&h=96&q=80',
tone: 'info',
plan: 'Enterprise',
count: '42 members',
},
{
value: 'support',
label: 'Support operations',
description: 'Ticket routing, SLAs, health alerts, and playbooks.',
initials: 'SO',
src: 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&crop=faces&w=96&h=96&q=80',
tone: 'warning',
plan: 'Business',
count: '11 members',
},
{
value: 'design',
label: 'Design systems',
description: 'Component governance, accessibility review, and release notes.',
initials: 'DS',
src: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&crop=faces&w=96&h=96&q=80',
tone: 'neutral',
plan: 'Team',
count: '9 members',
},
{
value: 'research',
label: 'Research lab',
description: 'Interview notes, insight tagging, and participant panels.',
initials: 'RL',
src: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?auto=format&fit=crop&crop=faces&w=96&h=96&q=80',
tone: 'info',
plan: 'Pro',
count: '6 members',
},
{
value: 'ops',
label: 'Operations hub',
description: 'Vendor reviews, procurement workflows, and launch checklists.',
initials: 'OH',
src: 'https://images.unsplash.com/photo-1544723795-3fb6469f5b39?auto=format&fit=crop&crop=faces&w=96&h=96&q=80',
tone: 'warning',
plan: 'Scale',
count: '24 members',
},
];
const current = computed(() => workspaces.find((item) => item.value === workspace.value));
</script>
<template>
<div class="grid w-full max-w-md gap-3">
<DomSelect
v-model="workspace"
label="Workspace"
description="Use DomSelect when options need richer markup than a native select can render."
:options="workspaces"
searchable
search-placeholder="Search workspaces..."
width="min-w-[22rem]"
>
<template #value>
<span v-if="current" class="flex min-w-0 items-center gap-2">
<DomAvatar :src="current.src" :name="current.label" :initials="current.initials" size="xs" />
<span class="truncate">{{ current.label }}</span>
<DomStatusPill :tone="current.tone" size="sm">{{ current.plan }}</DomStatusPill>
</span>
</template>
<template #option="{ option, selected }">
<span class="flex items-start gap-3">
<DomAvatar :src="option.src" :name="option.label" :initials="option.initials" size="sm" />
<span class="min-w-0 flex-1">
<span class="flex items-center gap-2">
<span class="truncate font-medium">{{ option.label }}</span>
<DomStatusPill :tone="option.tone" size="sm">{{ option.plan }}</DomStatusPill>
</span>
<span class="mt-1 block text-xs leading-5 text-muted-fg">{{ option.description }}</span>
<span class="mt-2 flex items-center gap-2">
<DomBadge size="sm" tone="neutral">{{ option.count }}</DomBadge>
<DomBadge v-if="selected" size="sm" tone="success">Selected</DomBadge>
</span>
</span>
</span>
</template>
</DomSelect>
<p class="text-xs text-muted-fg">Selected value: <code class="text-canvas-fg">{{ workspace }}</code></p>
</div>
</template>
Demo
Plan picker
Use a rich select for plan, workspace, role, provider, template, and project pickers instead of a native select.
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomBadge, DomSelect } from '@getdom/studio';
const plan = ref('pro');
const plans = [
{ value: 'starter', label: 'Starter', description: 'For personal tools and prototypes.', price: '$19', badge: 'Good for trials' },
{ value: 'pro', label: 'Pro', description: 'For small teams shipping customer workflows.', price: '$79', badge: 'Most popular' },
{ value: 'enterprise', label: 'Enterprise', description: 'For SSO, audit logs, governance, and dedicated support.', price: 'Custom', badge: 'Requires sales' },
];
</script>
<template>
<div class="w-full max-w-md">
<DomSelect v-model="plan" label="Plan" :options="plans" width="min-w-[20rem]">
<template #option="{ option, selected }">
<span class="flex items-start justify-between gap-4">
<span class="min-w-0">
<span class="flex items-center gap-2">
<span class="font-semibold">{{ option.label }}</span>
<DomBadge v-if="selected" size="sm" tone="success">Current</DomBadge>
</span>
<span class="mt-1 block text-xs leading-5 text-muted-fg">{{ option.description }}</span>
<DomBadge class="mt-2" size="sm" tone="neutral">{{ option.badge }}</DomBadge>
</span>
<span class="shrink-0 text-sm font-semibold text-canvas-fg">{{ option.price }}</span>
</span>
</template>
</DomSelect>
</div>
</template>
Demo
Grouped content types
Set option.group to add labelled, non-selectable sections with sticky headings. Each description stays hidden in a hover-and-focus popover, while a checkmark distinguishes the committed choice from the active row.
Choose the structure that best matches the reader's intent.
Selected: Guide / How-to
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, ref } from 'vue';
import { DomPopover, DomSelect } from '@getdom/studio';
const selectedType = ref('guide-how-to');
const contentTypes = [
{
id: 'guide-how-to',
typeLabel: 'Guide',
subtypeLabel: 'How-to',
description: 'Step-by-step guidance that helps readers complete a specific task with sequential, testable actions.',
},
{
id: 'guide-explainer',
typeLabel: 'Guide',
subtypeLabel: 'Explainer',
description: 'Educational content that builds understanding of a topic, concept, or process without forcing a step-by-step structure.',
},
{
id: 'guide-comparison',
typeLabel: 'Guide',
subtypeLabel: 'Focused comparison',
description: 'A focused side-by-side analysis of a small number of options for readers making a specific decision.',
},
{
id: 'guide-reference',
typeLabel: 'Guide',
subtypeLabel: 'Reference',
description: 'A scannable lookup resource for definitions, requirements, prices, statistics, benchmarks, or terminology.',
},
{
id: 'listicle-comparison',
typeLabel: 'Listicle',
subtypeLabel: 'Comparison list',
description: 'A multi-option list that evaluates products or services using the same practical criteria.',
},
{
id: 'listicle-round-up',
typeLabel: 'Listicle',
subtypeLabel: 'Round-up',
description: 'A curated collection of tips, recommendations, strategies, or ideas organized around a common theme.',
},
{
id: 'listicle-resources',
typeLabel: 'Listicle',
subtypeLabel: 'Resources',
description: 'A practical collection of external tools, software, templates, websites, or resources readers can use.',
},
{
id: 'listicle-examples',
typeLabel: 'Listicle',
subtypeLabel: 'Examples',
description: 'A showcase of real-world implementations, case studies, samples, or inspirational examples.',
},
];
const options = contentTypes.map(toSelectOption);
const selectedItem = computed(() => contentTypes.find((item) => item.id === selectedType.value));
/**
* Adapt an application-owned content type record to the DomSelect option contract.
*
* @param {{ id: string, typeLabel: string, subtypeLabel: string, description: string }} item Content type record.
* @returns {Object} Select option that retains the original application fields.
*/
function toSelectOption(item) {
return {
...item,
value: item.id,
label: item.subtypeLabel,
group: item.typeLabel,
};
}
</script>
<template>
<div class="grid w-full max-w-xl gap-3">
<DomSelect
v-model="selectedType"
label="Content type"
description="Choose the structure that best matches the reader's intent."
:options="options"
searchable
search-placeholder="Search content types..."
width="w-[min(34rem,calc(100vw-2rem))]"
>
<template #option="{ option, optionId }">
<span class="block font-medium">{{ option.label }}</span>
<DomPopover
:trigger-id="optionId"
trigger="hover"
position="end"
width="w-72 max-w-[calc(100vw-2rem)]"
padding="p-3"
>
<p class="leading-5 text-muted-fg">{{ option.description }}</p>
</DomPopover>
</template>
</DomSelect>
<p class="text-xs text-muted-fg">
Selected: <code class="text-canvas-fg">{{ selectedItem?.typeLabel }} / {{ selectedItem?.subtypeLabel }}</code>
</p>
</div>
</template>
Demo
Contact directory
A 52-contact directory exercises sticky headings from A through Z. Scroll the open list to watch each alphabetical group replace the previous heading.
Opens near Maya in the middle of the directory. Search remains empty until you type.
Selected: Maya Martin
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { computed, ref } from 'vue';
import { DomSelect } from '@getdom/studio';
const selectedContact = ref('maya-martin');
const contactGroups = [
{ letter: 'A', names: ['Aisha Adams', 'Alina Alvarez'] },
{ letter: 'B', names: ['Benjamin Brooks', 'Bianca Bennett'] },
{ letter: 'C', names: ['Camila Chen', 'Caleb Carter'] },
{ letter: 'D', names: ['Daniel Diaz', 'Daphne Dunn'] },
{ letter: 'E', names: ['Elias Evans', 'Elena Edwards'] },
{ letter: 'F', names: ['Fatima Foster', 'Felix Flores'] },
{ letter: 'G', names: ['Gabriel Green', 'Grace Gupta'] },
{ letter: 'H', names: ['Hana Hughes', 'Hugo Harris'] },
{ letter: 'I', names: ['Imani Ibrahim', 'Isaac Ingram'] },
{ letter: 'J', names: ['Jasmine Jones', 'Jonah Jensen'] },
{ letter: 'K', names: ['Kai Kim', 'Keira Khan'] },
{ letter: 'L', names: ['Lena Lewis', 'Luca Lopez'] },
{ letter: 'M', names: ['Maya Martin', 'Marcus Moore'] },
{ letter: 'N', names: ['Nina Nguyen', 'Noah Nelson'] },
{ letter: 'O', names: ['Olivia Ortiz', 'Omar Owens'] },
{ letter: 'P', names: ['Priya Patel', 'Peter Price'] },
{ letter: 'Q', names: ['Quinn Quade', 'Quentin Quinn'] },
{ letter: 'R', names: ['Rina Rivera', 'Rowan Reed'] },
{ letter: 'S', names: ['Sofia Singh', 'Samuel Scott'] },
{ letter: 'T', names: ['Talia Turner', 'Theo Taylor'] },
{ letter: 'U', names: ['Uma Usman', 'Uri Underwood'] },
{ letter: 'V', names: ['Valeria Vega', 'Victor Vaughn'] },
{ letter: 'W', names: ['Willow Walker', 'Wesley Wong'] },
{ letter: 'X', names: ['Ximena Xu', 'Xavier Xiong'] },
{ letter: 'Y', names: ['Yara Young', 'Yusuf Yilmaz'] },
{ letter: 'Z', names: ['Zara Zhang', 'Zane Zimmerman'] },
];
const roles = [
'Account manager',
'Customer success',
'Design lead',
'Engineering manager',
'Finance partner',
'Operations lead',
'Product manager',
'Support specialist',
];
const contacts = contactGroups.flatMap(buildContactGroup);
const currentContact = computed(() => contacts.find((contact) => contact.value === selectedContact.value));
/**
* Build the selectable contacts for one alphabetical group.
*
* @param {{ letter: string, names: string[] }} group Alphabetical contact group.
* @param {number} groupIndex Position of the group in the directory.
* @returns {Array<Object>} DomSelect option records for the group.
*/
function buildContactGroup(group, groupIndex) {
return group.names.map((name, contactIndex) => buildContact(name, group.letter, groupIndex, contactIndex));
}
/**
* Create a contact option with deterministic role and email metadata.
*
* @param {string} name Contact's display name.
* @param {string} letter Alphabetical group label.
* @param {number} groupIndex Position of the group in the directory.
* @param {number} contactIndex Position of the contact inside the group.
* @returns {Object} Contact option consumed by DomSelect.
*/
function buildContact(name, letter, groupIndex, contactIndex) {
const value = contactValue(name);
return {
value,
label: name,
group: letter,
description: roles[(groupIndex + contactIndex) % roles.length],
email: `${value.replace('-', '.')}@example.com`,
};
}
/**
* Convert a contact name into a stable option value.
*
* @param {string} name Contact's display name.
* @returns {string} Lowercase hyphenated contact identifier.
*/
function contactValue(name) {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
}
</script>
<template>
<div class="grid w-full max-w-lg gap-3">
<DomSelect
v-model="selectedContact"
label="Contact"
description="Opens near Maya in the middle of the directory. Search remains empty until you type."
:options="contacts"
searchable
search-placeholder="Search contacts..."
width="w-[min(30rem,calc(100vw-2rem))]"
>
<template #option="{ option }">
<span class="block min-w-0">
<span class="block truncate font-medium">{{ option.label }}</span>
<span class="mt-0.5 block truncate text-xs text-muted-fg">
{{ option.description }} · {{ option.email }}
</span>
</span>
</template>
</DomSelect>
<p class="text-xs text-muted-fg">
Selected: <code class="text-canvas-fg">{{ currentContact?.label }}</code>
</p>
</div>
</template>
Demo
Optional value
Include an option with value '' when the field should support an explicit unset state.
Selected value: (empty)
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomSelect, DomStatusPill } from '@getdom/studio';
const assignee = ref('');
const people = [
{ value: '', label: 'Not assigned', description: 'Leave this workflow unowned.' },
{ value: 'ada', label: 'Ada Lovelace', description: 'Product analytics' },
{ value: 'grace', label: 'Grace Hopper', description: 'Platform engineering' },
{ value: 'katherine', label: 'Katherine Johnson', description: 'Launch readiness' },
];
</script>
<template>
<div class="grid w-full max-w-sm gap-3">
<DomSelect
v-model="assignee"
label="Assignee"
placeholder="Choose a person"
:options="people"
width="min-w-[18rem]"
>
<template #option="{ option, selected }">
<span class="flex items-start justify-between gap-3">
<span class="min-w-0">
<span class="font-medium">{{ option.label }}</span>
<span class="mt-1 block text-xs leading-5 text-muted-fg">{{ option.description }}</span>
</span>
<DomStatusPill v-if="selected" size="sm" tone="success">Selected</DomStatusPill>
</span>
</template>
</DomSelect>
<p class="text-xs text-muted-fg">Selected value: <code class="text-canvas-fg">{{ assignee || '(empty)' }}</code></p>
</div>
</template>
Demo
Server-loaded multi-select
A real cursor-paginated HTTP endpoint; multiple selections survive searching and appending pages.
Select several people across pages. Searching never removes a committed reviewer.
0 of 0 results loaded. Scroll for more; search resets the pages.
Selected values: steve
npm install @getdom/studio<script setup>
import '@getdom/studio/style.css';
import { ref } from 'vue';
import { DomSelect } from '@getdom/studio';
import { usePagedLookup } from '../../_shared/usePagedLookup.js';
import LookupStatus from '../../_shared/LookupStatus.vue';
const owners = ref(['steve']);
// A separately resolved record supplies the label before its remote page arrives.
const selectedOptions = [{ value: 'steve', label: 'Steve Obrien' }];
const { options, loading, hasMore, error, total, search, loadMore } = usePagedLookup('people');
</script>
<template>
<div class="grid w-full max-w-md gap-3">
<DomSelect v-model="owners" :options="options" :selected-options="selectedOptions" :loading="loading" :has-more="hasMore" :filter-options="false" searchable multiple clearable label="Project reviewers" description="Select several people across pages. Searching never removes a committed reviewer." width="w-[min(28rem,calc(100vw-2rem))]" @query="search" @load-more="loadMore">
<template #option="{ option }">
<span class="block truncate font-medium">{{ option.label }}</span>
<span class="block truncate text-xs text-muted-fg">{{ option.email }}</span>
</template>
</DomSelect>
<LookupStatus :error="error" :count="options.length" :total="total" @retry="loadMore" />
<p class="text-xs text-muted-fg">Selected values: <code>{{ owners.join(', ') || 'None' }}</code></p>
</div>
</template>
Behaviour
Selection, search and remote pages
A selected value is not a search filter. Opening shows the loaded options and a checkmark on matching selections. Only typing filters; custom item/option slots receive query and selected.
Set :filter-options="false" for server-ranked results. Handle @query to reset the cursor and replace options, then @load-more to append the next page. Pass loading and hasMore. Scrolling, keyboard navigation at the last row, and the Load more button request another page.
The examples call /api/form-options, a real HTTP endpoint serving synthetic records. The shared example loader debounces queries, aborts old requests, ignores stale responses, deduplicates pages and exposes a Retry button. Applications own their endpoint, authorization and cursor protocol; the controls do not fetch data themselves.
Selected labels are retained when a page is replaced. For Select, Combobox and Tag combobox, selectedOptions can supply initial records that are not loaded yet. Autocomplete stores the label as free text. Select opens near the selected row when it is loaded; a remote selection on a later page requires an application-owned lookup or seek endpoint. Multi-select opens at the beginning.
Reference
Props
Control props
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
modelValue | string | number | array | Array<unknown> | '' | Selected option value. |
placeholder | string | string | 'Select an option' | Text shown when no option is selected. |
options*ts | array | Array< | — | Available options. Set group for non-selectable section headings and use the option slot for rich rows. |
placement | 'bottom' | 'top' | 'right' | 'left' | string | 'bottom' | Preferred side before collision handling. |
align | 'left' | 'right' | string | 'left' | Horizontal panel alignment. |
floatingMode | 'viewport' | 'anchor' | string | 'viewport' | viewport keeps the list inside the browser; anchor keeps it attached while scrolling. |
width | string | string | 'min-w-[14rem]' | Tailwind width utility for the floating listbox. |
optionTone | string | string | 'accent' | Theme token basename for hovered, focused, and selected options. For example, primary uses var(--primary) and var(--primary-fg). |
searchable | boolean | string | string | false | Show a search input inside the option panel while keeping select-style committed values. |
searchPlaceholder | string | string | 'Search options...' | Placeholder shown inside the panel search input. |
multiple | boolean | string | string | false | Toggle multiple values without closing the panel. Use an array v-model. |
filterOptions | boolean | string | string | true | Disable local filtering for server-ranked options. |
loading | boolean | string | string | false | — |
hasMore | boolean | string | string | false | — |
selectedOptions | array | Array<unknown> | [] | Resolved records for initial values outside the currently loaded server page. |
clearable | boolean | string | string | false | — |
Field props
| Name | Type | TS | Default | Description |
|---|---|---|---|---|
id | string | string | '' | Optional ID override. By default parent forms derive the input ID from the field path using underscores. |
name | string | string | '' | Local field name. Parent forms derive the full field path and native HTML name from the form hierarchy. |
label | string | string | '' | Visible field label. |
description | string | string | '' | Optional helper copy below the field. |
required | boolean | boolean | false | Mark the field as required. |
disabled | boolean | boolean | false | Disable field interaction. |
readOnly | boolean | boolean | false | Show the value but prevent editing. |
invalid | boolean | boolean | false | Mark the field invalid. |
errorsts | array | object | string | Array< | [] | Validation errors for this field. |
visible | boolean | boolean | true | Show or hide the field. |
validators | array | Array<unknown> | [] | Validators attached to this field. Use functions in Vue code, or serializable records such as { name: "minLength", props: { min: 2 } } in generated schemas. |
validateOnBlur | boolean | boolean | true | Run validators when the field loses focus. |
chrome | 'field' | 'none' | false | string | 'field' | Render default field chrome, or hide chrome while keeping form state wiring. |
Auto-generated from Select.props and inline _edit hints.
Slots
| Name | Scope | Description |
|---|---|---|
| #value | { option, options, value, label, placeholder } | Custom selected-value markup inside the trigger. options contains all selected records in multiple mode. |
| #option | { option, index, selected, query, optionId } | Custom option markup with the transient query for safe text highlighting. optionId identifies the focusable row. |
| #group | { group, options, index } | Custom heading markup for options that share a group. |
Events
| Name | Payload | Description |
|---|---|---|
| @update:modelValue | string | number | Array<string | number> | Emitted with one committed value or an array in multiple mode. |
| @select | ({ option, value, label }) | Emitted with the selected option record. |
| @query | string | Emitted on open with an empty query and as the user types in the separate search input. |
| @load-more | { query } | Request another page when hasMore is true. The application owns cursors, requests, errors and appending options. |
| @list-scroll | Event | Scroll event for optional visibility-based previews. |
| @open | — | Emitted when the panel opens. |
| @close | — | Emitted when the panel closes. |
| @focus | — | — |
| @blur | — | — |
Names auto-detected from defineEmits and source emit() calls; payload and description from __doc.events when present.
Keyboard
- Enter / Space / ↓Open the listbox.
- ↑ / ↓Move active option.
- Type in searchFilter visible options when searchable is enabled.
- Enter / SpaceSelect active option.
- Esc / TabClose and return focus.