Skip to content

ComboBox Fields

ComboBoxes are common form fields. They combine text fields with a dropdown or a listbox to allow users to select an option from the list that matches the text they have entered.

  • Labeling, descriptions, error message displays are automatically linked to input and label elements with aria-* attributes.
  • Support for single selections.
  • Support for option groups.
  • Support for option filtering with multiple filtering strategies.
  • First-class Support for [popover] popups for dropdowns/menus.
  • Generic typing support for options.
  • Validation support with HTML validation attributes and Standard Schemas.
  • Support for v-model binding.
  • Supported Keyboard features:

When focusing the text input, and menu is closed:

KeyDescription
ArrowDown
Opens the options menu.
ArrowUp
Opens the options menu.
EscEscape
Clears the input value.

When focusing the text input, and menu is open:

KeyDescription
Enter
Selects the highlighted option.
Arrow Down
Highlights the next option if available.
Arrow Up
Highlights the previous option if available.
Home
Highlights the first option.
Page Up
Highlights the first option.
End
Highlights the last option.
Page Down
Highlights the last option.
EscEscape
Closes the options menu if open.

Most people aren’t aware of these keyboard shortcuts, but they do make a big difference in the user experience for keyboard users.

Label
Label
Search
Search Query
Trigger Button
Input
Help text
Description or Error Message

The combobox field is one of the most complex fields with multiple moving parts. It is what Formwerk considers a “compound component” because it is made up of an ecosystem of components that work together to create a single field.

From the anatomy diagram above, you can see that the core combobox field is made up of the following parts:

  • Input: A text input that is used to filter the options and trigger the Listbox.
  • Button: A trigger element that is used to open the Listbox popup.
  • Listbox: An options menu that contains the list of options.
  • Option: An option component that represents each option in the list.
  • Option Group: Not illustrated above, an option group that groups options together based on some categorization.

Formwerk handles all of these parts and abstracts them into the following component ecosystem:

  • ComboBox: Contains the input, button, and listbox parts. You will use useComboBox to build it.
  • Option: Represents an option in the list. You will use useOption to build it.
  • OptionGroup: Represents a group of options in the list. You will use useOptionGroup to build it.

Notice that the listbox is not a separate component. This is because popups today can be done in different ways, so Formwerk while offers the open state along with some accessability attributes, it does not have a specific popup component implementation. But we offer out of the box support for Popover API if you happen to use one for the listbox element.

We can start by using useOption to create our option component. This component will be responsible for rendering each option in the list.

<script setup lang="ts">
import { type OptionProps, useOption } from '@formwerk/core';
const props = defineProps<OptionProps>();
const { optionProps } = useOption(props);
</script>
<template>
<div v-bind="optionProps" class="option">
{{ label }}
</div>
</template>
<style scoped>
.option {
user-select: none;
padding: 2px 8px;
border-radius: 4px;
font-size: 14px;
border: 1px solid transparent;
transition:
background-color 0.2s,
color 0.2s,
border-color 0.2s;
&:hover {
background-color: #e4e4e7;
}
&[aria-selected='true'],
&[aria-checked='true'] {
background-color: #10b981;
color: #fff;
}
&:focus {
outline: none;
border-color: #10b981;
}
&[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
}
</style>

Notice that we are using the OptionProps type to define the props for our component. This type is generic and allows you to specify the type of the option value. In this case, we are using any to represent any type of value.

The label is available inside OptionProps and we will display it to the user. Option components can also be used in other fields like select fields.

Next, you will use useComboBox to create our combobox component. This component will be responsible for rendering the trigger and popup parts.

The useComboBox composable returns binding objects for some of the elements shown in the anatomy, you will use v-bind to bind them to the corresponding DOM elements.

<script setup lang="ts">
import ComboBox from './ComboBox.vue';
import OptionItem from './OptionItem.vue';
</script>
<template>
<ComboBox label="Select a drink" placeholder="Search...">
<OptionItem label="Coffee ☕️" value="coffee" />
<OptionItem label="Tea 🍵" value="tea" />
<OptionItem label="Milk 🥛" value="milk" />
</ComboBox>
</template>
<script setup lang="ts">
import { useId } from 'vue';
import {
useComboBox,
type ComboBoxProps,
useDefaultFilter,
} from '@formwerk/core';
const props = defineProps<ComboBoxProps>();
const id = useId();
const anchorId = `--anchor-${id}`;
const { contains } = useDefaultFilter({
caseSensitive: false,
});
const {
buttonProps,
inputProps,
labelProps,
errorMessageProps,
errorMessage,
listBoxProps,
isListEmpty,
} = useComboBox(props, {
filter: contains,
});
</script>
<template>
<div class="combobox">
<div v-bind="labelProps" class="combobox-label">{{ label }}</div>
<div class="control">
<input v-bind="inputProps" />
<button v-bind="buttonProps">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 256 256"
>
<path
d="M181.66,170.34a8,8,0,0,1,0,11.32l-48,48a8,8,0,0,1-11.32,0l-48-48a8,8,0,0,1,11.32-11.32L128,212.69l42.34-42.35A8,8,0,0,1,181.66,170.34Zm-96-84.68L128,43.31l42.34,42.35a8,8,0,0,0,11.32-11.32l-48-48a8,8,0,0,0-11.32,0l-48,48A8,8,0,0,0,85.66,85.66Z"
></path>
</svg>
</button>
</div>
<div v-bind="listBoxProps" popover class="listbox">
<slot />
<div v-if="isListEmpty" class="empty-message">No Results</div>
</div>
<div v-if="description" v-bind="descriptionProps" class="hint">
{{ description }}
</div>
<div v-if="errorMessage" v-bind="errorMessageProps" class="error">
{{ errorMessage }}
</div>
</div>
</template>
<style scoped>
.combobox {
--color-text: #333;
--color-hint: #666;
--color-focus: #059669;
--color-error: #f00;
--color-hover: #eee;
--color-border: #d4d4d8;
display: flex;
flex-direction: column;
width: max-content;
.combobox-label {
color: var(--color-text);
display: block;
margin-bottom: 0.25rem;
font-size: 14px;
font-weight: 500;
}
.control {
display: flex;
align-items: center;
justify-content: space-between;
/** CSS Anchor Positioning */
anchor-name: v-bind('anchorId');
border: 1px solid var(--color-border);
font-size: 14px;
color: var(--color-text);
border-radius: 0.375rem;
user-select: none;
cursor: pointer;
svg {
margin-left: 4px;
}
&:focus {
outline: none;
border: 1px solid var(--color-focus);
}
button {
background: transparent;
border: none;
cursor: pointer;
}
input {
border: none;
height: 100%;
width: 100%;
outline: none;
padding: 4px 8px;
background: transparent;
}
}
.listbox {
padding: 0;
inset: auto;
position: relative;
background: #fff;
border: 1px solid #e5e7eb;
max-height: 40vh;
opacity: 0;
border-radius: 6px;
margin: 0;
width: 250px;
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
transition:
display 0.2s allow-discrete,
opacity 0.2s allow-discrete,
transform 0.2s allow-discrete,
overlay 0.2s allow-discrete;
&:popover-open {
opacity: 1;
}
padding: 8px;
&:has(.option-group) {
padding: 0;
}
/** CSS Anchor Positioning */
position-anchor: v-bind('anchorId');
inset-area: bottom center;
position-area: bottom center;
position-try-fallbacks:
flip-block,
flip-inline,
flip-block flip-inline;
position-try-order: max-height;
scrollbar-width: thin;
overflow-y: auto;
overflow-y: overlay;
scrollbar-color: rgb(192 192 185) transparent;
}
.hint,
.error {
margin-top: 0.25rem;
}
.error {
color: var(--color-error);
display: none;
font-size: 13px;
}
.hint {
color: var(--color-hint);
font-size: 13px;
opacity: 0;
transition: opacity 0.3s ease;
}
&:has(:focus) {
.hint {
opacity: 1;
}
}
&:has([aria-invalid='true']) {
.hint {
display: none;
}
.error {
display: block;
}
}
.empty-message {
color: var(--color-text);
font-size: 13px;
}
}
@starting-style {
.listbox:popover-open {
opacity: 0;
}
}
</style>
<script setup lang="ts">
import { type OptionProps, useOption } from '@formwerk/core';
const props = defineProps<OptionProps>();
const { optionProps } = useOption(props);
</script>
<template>
<div v-bind="optionProps" class="option">
{{ label }}
</div>
</template>
<style scoped>
.option {
user-select: none;
padding: 2px 8px;
border-radius: 4px;
font-size: 14px;
border: 1px solid transparent;
transition:
background-color 0.2s,
color 0.2s,
border-color 0.2s;
&:hover {
background-color: #e4e4e7;
}
&[aria-selected='true'],
&[aria-checked='true'] {
background-color: #10b981;
color: #fff;
}
&:focus {
outline: none;
border-color: #10b981;
}
&[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
}
</style>

Building an option group component is similar to building an option component. You will use useOptionGroup to create the component.

<script setup lang="ts">
import ComboBox from './ComboBox.vue';
import OptionGroup from './OptionGroup.vue';
import OptionItem from './OptionItem.vue';
const groups = [
{ label: 'Africa', options: ['Egypt', 'Nigeria', 'Ghana', 'Kenya'] },
{ label: 'Asia', options: ['China', 'India', 'Japan'] },
{ label: 'Europe', options: ['France', 'Germany', 'Italy'] },
{ label: 'North America', options: ['Canada', 'Mexico', 'United States'] },
{ label: 'South America', options: ['Argentina', 'Brazil', 'Chile'] },
];
</script>
<template>
<ComboBox label="Select a country" placeholder="Search...">
<OptionGroup
v-for="group in groups"
:key="group.label"
:label="group.label"
>
<OptionItem
v-for="country in group.options"
:key="country"
:label="country"
:value="country"
/>
</OptionGroup>
</ComboBox>
</template>
<script setup lang="ts">
import { type OptionGroupProps, useOptionGroup } from '@formwerk/core';
const props = defineProps<OptionGroupProps>();
const { labelProps, groupProps } = useOptionGroup(props);
</script>
<template>
<div v-bind="groupProps" class="option-group">
<div v-bind="labelProps" class="label">{{ label }}</div>
<div class="options">
<!-- Options will be slotted here -->
<slot />
</div>
</div>
</template>
<style scoped>
.option-group {
.label {
padding: 4px 8px;
user-select: none;
position: sticky;
font-weight: 600;
background-color: #fff;
top: 0;
}
.options {
padding: 2px 8px;
display: flex;
flex-direction: column;
gap: 2px;
}
}
</style>

OptionGroup components can also be used in other fields like select fields.

Because selects in Formwerk are a fully custom component, it doesn’t support any HTML validation attributes. You can however, use Standard Schemas to validate the value of the select.

In the future when customizable selects are available in the browser, Formwerk will leverage that to provide better validation support for the native HTML constraints.

useComboBox supports Standard Schema validation through the schema prop. This includes multiple providers like Zod, Valibot, Arktype, and more.

<script setup lang="ts">
import ComboBox from './ComboBox.vue';
import OptionItem from './OptionItem.vue';
import { z } from 'zod';
const schema = z
.string()
.min(1, 'Please select a drink')
.endsWith('coffee', 'WRONG ANSWER!');
</script>
<template>
<ComboBox label="Select a drink" :schema="schema" placeholder="Search...">
<OptionItem label="Coffee ☕️" value="coffee" />
<OptionItem label="Tea 🍵" value="tea" />
<OptionItem label="Milk 🥛" value="milk" />
</ComboBox>
</template>

You probably have noticed we are using useDefaultFilter to filter the options using the contains strategy. By default, comboboxes do not know how to filter the options.

Aside from the contains strategy, Formwerk also offers the startsWith, endsWith and equals strategies.

import { useDefaultFilter } from '@formwerk/core';
const { startsWith, endsWith, equals } = useDefaultFilter({
caseSensitive: false,
});

Filtering runs on every keystroke, but you can debounce it by passing the debounceMs option to useDefaultFilter.

import { useDefaultFilter } from '@formwerk/core';
const { startsWith, endsWith, equals } = useDefaultFilter({
caseSensitive: false,
debounceMs: 300,
});

Use disabled to mark fields as non-interactive, disabled fields are not validated and are not submitted.

If you need to prevent the user from interacting with the field while still allowing it to submit, consider using readonly instead.

<script setup lang="ts">
import ComboBox from './ComboBox.vue';
import OptionGroup from './OptionGroup.vue';
import OptionItem from './OptionItem.vue';
</script>
<template>
<ComboBox label="Pick a drink" disabled>
<OptionItem label="Coffee ☕️" value="coffee" />
<OptionItem label="Tea 🍵" value="tea" />
<OptionItem label="Milk 🥛" value="milk" />
</ComboBox>
</template>

You can also mark individual options as disabled by passing a disabled prop to the OptionItem component. Note that disabled options are skipped from the focus order when using shortcuts or the search functionality.

<script setup lang="ts">
import ComboBox from './ComboBox.vue';
import OptionGroup from './OptionGroup.vue';
import OptionItem from './OptionItem.vue';
</script>
<template>
<ComboBox label="Pick a drink" placeholder="Search...">
<OptionItem label="Coffee ☕️" value="coffee" />
<OptionItem label="Tea 🍵" disabled value="tea" />
<OptionItem label="Milk 🥛" value="milk" />
</ComboBox>
</template>

Readonly fields are validated and submitted, but they do not accept user input. The field is still focusable and the popup is still openable. For more info, check the MDN.

<script setup lang="ts">
import ComboBox from './ComboBox.vue';
import OptionGroup from './OptionGroup.vue';
import OptionItem from './OptionItem.vue';
</script>
<template>
<ComboBox label="Pick a drink" readonly placeholder="Search...">
<OptionItem label="Coffee ☕️" value="coffee" />
<OptionItem label="Tea 🍵" value="tea" />
<OptionItem label="Milk 🥛" value="milk" />
</ComboBox>
</template>

You can open the listbox on focus by passing the openOnFocus prop.

<script setup lang="ts">
import ComboBox from './ComboBox.vue';
import OptionGroup from './OptionGroup.vue';
import OptionItem from './OptionItem.vue';
</script>
<template>
<ComboBox label="Pick a drink" open-on-focus placeholder="Search...">
<OptionItem label="Coffee ☕️" value="coffee" />
<OptionItem label="Tea 🍵" value="tea" />
<OptionItem label="Milk 🥛" value="milk" />
</ComboBox>
</template>

ComboBoxes by default do not allow custom values, you probably noticed that every time you blurred the text field without making a selection, the input value was reverted to the last value.

If you need to allow custom values, you can pass the onNewValue prop/handler to useComboBox. The onNewValue prop is a function that must return the both the new value and the label to use for it.

<script setup lang="ts">
import { ref } from 'vue';
import ComboBox from './ComboBox.vue';
import OptionItem from './OptionItem.vue';
const drinks = ref([
{ value: 'coffee', label: 'Coffee ☕️' },
{ value: 'tea', label: 'Tea 🍵' },
{ value: 'milk', label: 'Milk 🥛' },
]);
function onNewDrink(value: string) {
const newDrink = { value, label: `${value} 🍹` };
drinks.value.push(newDrink);
return newDrink;
}
</script>
<template>
<ComboBox
label="Select a drink"
placeholder="Search..."
@new-value="onNewDrink"
>
<OptionItem
v-for="drink in drinks"
:key="drink.value"
:label="drink.label"
:value="drink.value"
/>
</ComboBox>
</template>

The onNewValue handler is called when all of the following conditions are met:

  • The current input value does not match any option value.
  • The current input value is not empty.
  • The user presses the Enter or the Tab keys, blurring the text field otherwise has no effect.

You can also return null or undefined to prevent the new value from being added to the list.

<script setup lang="ts">
import { ref } from 'vue';
import ComboBox from './ComboBox.vue';
import OptionItem from './OptionItem.vue';
const drinks = ref([
{ value: 'coffee', label: 'Coffee ☕️' },
{ value: 'tea', label: 'Tea 🍵' },
{ value: 'milk', label: 'Milk 🥛' },
]);
function onNewDrink(value: string) {
// Prevent adding more than 5 drinks
if (drinks.value.length >= 5) {
return null;
}
const newDrink = { value, label: `${value} 🍹` };
drinks.value.push(newDrink);
return newDrink;
}
</script>
<template>
<ComboBox
label="Select a drink"
placeholder="Search..."
@new-value="onNewDrink"
>
<OptionItem
v-for="drink in drinks"
:key="drink.value"
:label="drink.label"
:value="drink.value"
/>
</ComboBox>
</template>

When styling the select field, you need to be aware of the following…

When an option is focused, it will receive the aria-selected="true" attribute.

The text input element will receive the aria-expanded attribute. So you can style the trigger according to the popover state if needed.

It will also receive the aria-activedescendant attribute when an option is focused.

These are the properties that can be passed to the useOption composable.

NameTypeDescription
disabledWhether the option is disabled.
labelThe label text for the option.
valueThe value associated with this option.

These are the properties in the object returned by the useOption composable.

NameTypeDescription
isDisabledWhether the option is disabled.
isSelectedWhether the option is selected.
optionElReference to the option element.
optionPropsProps for the option element.

These are the properties that can be passed to the useOptionGroup composable.

NameTypeDescription
disabledWhether the option group is disabled.
labelThe label text for the option group.

These are the properties in the object returned by the useOptionGroup composable.

NameTypeDescription
groupElReference to the group element.
groupProps
Ref<{ 'aria-label'?: string; 'aria-labelledby'?: string; id: string; role: string; }>
Props for the group element.
isDisabledWhether the option group is disabled.
labelPropsProps for the label element.

These are the properties that can be passed to the useComboBox composable.

NameTypeDescription

These are the properties in the object returned by the useSelect composable.

NameTypeDescription
buttonEl
buttonProps
Ref<{ [x: string]: unknown; type: "button"; role: string; tabindex: string; }>
controlId
descriptionPropsProps for the description element.
errorMessageThe error message for the field.
errorMessagePropsProps for the error message element.
errorsThe errors for the field.
field
fieldValueThe value of the field.
inputEl
inputProps
Ref<{ onInput?: EventHandler; onChange?: EventHandler; onBlur?: EventHandler; onBeforeinput?: EventHandler; onInvalid?: EventHandler; ... 13 more ...; value: string; }>
inputValue
isBlurredWhether the field is blurred.
isDirtyWhether the field is dirty.
isDisabledWhether the field is disabled.
isListEmpty
isPopupOpen
isTouchedWhether the field is touched.
isValidWhether the field is valid.
isValidatedWhether the field is validated, used to determine whether to show validation errors or not.
labelPropsProps for the label element.
listBoxEl
listBoxProps
selectedOption
setBlurredSets the blurred state for the field.
setErrors
(messages: Arrayable<string>) => void
Sets the errors for the field.
setIsValidatedSets the validated state for the field.
setTouchedSets the touched state for the field.
setValueSets the value for the field.
submitErrorMessageThe error message for the field from the last submit attempt.
submitErrorsThe errors for the field from the last submit attempt.
validate
(mutate?: boolean) => Promise<ValidationResult<unknown>>
Validates the field.
NameTypeDescription
caseSensitive
debounceMs