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.

Features

  • 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.

Anatomy

Label
Label
Search
Search Query
Trigger Button
Input
Help text
Description or Error Message
Label
Search
Option 1
Option 2
Option item
Option 3
ListBox

Building a ComboBox Field

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.

Building an Option Component

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>
33 collapsed lines
<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.

Building a ComboBox Component

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>
150 collapsed lines
<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>
33 collapsed lines
<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

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>
19 collapsed lines
<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.

Validation

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.

Standard Schema

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>

Filtering

Default Filter

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,
});

Debouncing

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,
});

Usage

Disabled

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

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>

Open listbox on focus

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>

Custom Values

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>

Styling

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

Option attributes

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

Input attributes

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.

API

Option

Props

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.

Returns

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.

Option Group

Props

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.

Returns

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.

ComboBox

Props

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

NameTypeDescription
descriptionDescription text for the select field.
disabledWhether the select field is disabled.
disableHtmlValidationWhether to disable HTML5 validation.
labelThe label text for the select field.
modelValueThe v-model value of the select field.
nameThe name of the select field.
onNewValue
(value: string) => { label: string; value: TValue; }
Function to create a new option from the user input.
openOnFocusWhether to open the popup when the input is focused.
orientationThe orientation of the listbox popup (vertical or horizontal).
placeholderPlaceholder text when no option is selected.
readonlyWhether the select field is readonly.
requiredWhether the select field is required.
schemaSchema for validating the select field value.
valueThe controlled value of the select field.

Returns

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

NameTypeDescription
buttonElReference to the button element that opens the popup.
buttonProps
Ref<{ [x: string]: string | boolean | (() =>
Props for the button element that toggles the popup.
descriptionPropsProps for the description element.
displayErrorDisplay the error message for the field.
errorMessageThe error message for the field.
errorMessageProps
Ref<{ 'aria-live': "polite"; 'aria-atomic': boolean; id: string; }>
Props for the error message element.
errorsThe errors for the field.
fieldValueThe value of the field.
inputElReference to the input element.
inputProps
Ref<{ onInput?: EventHandler; onChange?: EventHandler; onBlur?: EventHandler; onBeforeinput?: EventHandler; onInvalid?: EventHandler; ... 13 more ...; value: string; }>
Props for the input element.
inputValueThe value of the text field, will contain the label of the selected option or the user input if they are currently typing.
isDirtyWhether the field is dirty.
isDisabledWhether the field is disabled.
isListEmptyWhether the listbox is empty, i.e. no options are visible.
isPopupOpenWhether the popup is open.
isTouchedWhether the field is touched.
isValidWhether the field is valid.
labelPropsProps for the label element.
listBoxElReference to the listbox element.
listBoxPropsProps for the listbox/popup element.
selectedOption
Ref<{ id: string; value: TValue; label: string; }>
The selected option.
setErrors
(messages: Arrayable<string>) => void
Sets the errors 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.
validityDetailsValidity details for the input element.

useDefaultFilter

NameTypeDescription
caseSensitive
debounceMs