Combobox
A single-select that opens a searchable list, sized to the field it came from.
<script setup lang="ts">
import type { FieldOption } from '@core/schema/types'
import SaCombobox from '@shared/components/common/SaCombobox.vue'
import { ref } from 'vue'
const COUNTRIES: FieldOption[] = [
{ label: 'Brazil', value: 'br' },
{ label: 'Germany', value: 'de' },
{ label: 'Indonesia', value: 'id' },
{ label: 'Japan', value: 'jp' },
{ label: 'Netherlands', value: 'nl' },
{ label: 'Nigeria', value: 'ng' },
{ label: 'Portugal', value: 'pt' },
{ label: 'Sweden', value: 'se' },
{ label: 'Türkiye', value: 'tr' },
{ label: 'United Kingdom', value: 'gb' },
]
const country = ref<string | number | undefined>()
</script>
<template>
<div class="max-w-sm space-y-3">
<SaCombobox
v-model="country"
:options="COUNTRIES"
placeholder="Choose a country"
search-placeholder="Search countries"
empty-text="No country by that name"
/>
<p class="text-sm text-muted-foreground">
Selected: {{ country ?? 'nothing yet' }}
</p>
</div>
</template>Usage
Bind one value with v-model and pass options as FieldOption[], which is { label, value, disabled? }. The value may be a string or a number, and the control compares by string, so 1 and '1' both select the same option.
A plain select is right while the whole list is visible at once. Past that, scrolling a popup to find one entry is the wrong interaction, which is what this control replaces. The form engine picks between the two by option count, so the data decides rather than the schema author. See Schema.
Examples
Disabled options
An option carrying disabled stays in the list, grayed and unselectable, which is how you show a tier someone has to ask for rather than hiding it.
<script setup lang="ts">
import type { FieldOption } from '@core/schema/types'
import SaCombobox from '@shared/components/common/SaCombobox.vue'
import { ref } from 'vue'
const PLANS: FieldOption[] = [
{ label: 'Starter', value: 'starter' },
{ label: 'Team', value: 'team' },
{ label: 'Business', value: 'business' },
{ label: 'Enterprise (contact sales)', value: 'enterprise', disabled: true },
]
const plan = ref<string | number | undefined>('team')
</script>
<template>
<div class="max-w-sm">
<SaCombobox v-model="plan" :options="PLANS" placeholder="Choose a plan" />
</div>
</template>Disabled
disabled blocks the whole control. The current selection still reads.
<script setup lang="ts">
import type { FieldOption } from '@core/schema/types'
import SaCombobox from '@shared/components/common/SaCombobox.vue'
import { ref } from 'vue'
const REGIONS: FieldOption[] = [
{ label: 'Europe', value: 'eu' },
{ label: 'North America', value: 'na' },
]
const region = ref<string | number | undefined>('eu')
</script>
<template>
<div class="max-w-sm">
<SaCombobox v-model="region" :options="REGIONS" disabled />
</div>
</template>Invalid, required and described
invalid, required and describedBy are props rather than attributes. The component's root is a popover root, which renders no element of its own, so a fallthrough aria-* would have nowhere to land. Passing them by name puts them on the trigger.
<script setup lang="ts">
import type { FieldOption } from '@core/schema/types'
import SaCombobox from '@shared/components/common/SaCombobox.vue'
import { Label } from '@shared/components/ui/label'
import { ref } from 'vue'
const OWNERS: FieldOption[] = [
{ label: 'Amina Haddad', value: 'amina' },
{ label: 'Luca Rossi', value: 'luca' },
{ label: 'Mei Tanaka', value: 'mei' },
]
const owner = ref<string | number | undefined>()
</script>
<template>
<div class="max-w-sm space-y-2">
<Label for="deal-owner">Deal owner</Label>
<SaCombobox
id="deal-owner"
v-model="owner"
:options="OWNERS"
required
:invalid="!owner"
described-by="deal-owner-error"
placeholder="Choose an owner"
/>
<p v-if="!owner" id="deal-owner-error" class="text-sm text-destructive">
Every deal needs an owner.
</p>
</div>
</template>API
Source: src/shared/components/common/SaCombobox.vue
Props
| Prop | Type | Default | Description |
|---|---|---|---|
modelValue | string | number | — | The selected value. Compared to each option as a string. |
options | FieldOption[] | required | { label, value, disabled? }. A label that is a translation key is translated. |
placeholder | string | 'field.selectPrompt' | The trigger text while nothing is selected. The default renders as "Select". |
searchPlaceholder | string | 'field.searchPrompt' | The search box text. The default renders as "Search…". |
emptyText | string | 'field.nothingFound' | Shown when the search matches nothing. The default renders as "Nothing found". |
disabled | boolean | false | Blocks the trigger. |
id | string | — | Put on the trigger, so a <Label for> points at it. |
invalid | boolean | false | Sets aria-invalid on the trigger. |
required | boolean | — | Sets aria-required on the trigger. |
describedBy | string | — | Sets aria-describedby on the trigger, for an error or hint element. |
The three text defaults are translation keys, not English. They resolve at render, so they follow the language switcher, and a string you pass wins over them.
Emits
| Event | Payload | Description |
|---|---|---|
update:modelValue | value: string | number | undefined | Fires on select. The popover closes with it. |
Slots
None.
Accessibility
- The trigger is a button with
role="combobox"andaria-expanded, and carriesaria-invalid,aria-requiredandaria-describedbywhen you pass them. - The list is a command list: typing filters it,
ArrowUpandArrowDownmove the active item,Enterselects andEscapecloses. - The selected option is marked with a check, so the state does not rest on color.
- The popover is sized with the trigger's own width, so it reads as the field's list rather than a stray floating menu.