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:
Key | Description |
---|---|
↓ArrowDown | Opens the options menu. |
↑ArrowUp | Opens the options menu. |
EscEscape | Clears the input value. |
When focusing the text input, and menu is open:
Key | Description |
---|---|
↩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
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 { 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>
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 { 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 theTab
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.
Name | Type | Description |
---|---|---|
disabled | Whether the option is disabled. | |
label | The label text for the option. | |
value | The value associated with this option. |
Returns
These are the properties in the object returned by the useOption
composable.
Name | Type | Description |
---|---|---|
isDisabled | Whether the option is disabled. | |
isSelected | Whether the option is selected. | |
optionEl | Reference to the option element. | |
optionProps | Props for the option element. |
Option Group
Props
These are the properties that can be passed to the useOptionGroup
composable.
Name | Type | Description |
---|---|---|
disabled | Whether the option group is disabled. | |
label | The label text for the option group. |
Returns
These are the properties in the object returned by the useOptionGroup
composable.
Name | Type | Description |
---|---|---|
groupEl | Reference to the group element. | |
groupProps | Ref<{ 'aria-label'?: string; 'aria-labelledby'?: string; id: string; role: string; }> | Props for the group element. |
isDisabled | Whether the option group is disabled. | |
labelProps | Props for the label element. |
ComboBox
Props
These are the properties that can be passed to the useComboBox
composable.
Name | Type | Description |
---|---|---|
description | Description text for the select field. | |
disabled | Whether the select field is disabled. | |
disableHtmlValidation | Whether to disable HTML5 validation. | |
label | The label text for the select field. | |
modelValue | The v-model value of the select field. | |
name | The name of the select field. | |
onNewValue | (value: string) => { label: string; value: TValue; } | Function to create a new option from the user input. |
openOnFocus | Whether to open the popup when the input is focused. | |
orientation | The orientation of the listbox popup (vertical or horizontal). | |
placeholder | Placeholder text when no option is selected. | |
readonly | Whether the select field is readonly. | |
required | Whether the select field is required. | |
schema | Schema for validating the select field value. | |
value | The controlled value of the select field. |
Returns
These are the properties in the object returned by the useSelect
composable.
Name | Type | Description |
---|---|---|
buttonEl | Reference to the button element that opens the popup. | |
buttonProps | Props for the button element that toggles the popup. | |
descriptionProps | Props for the description element. | |
displayError | Display the error message for the field. | |
errorMessage | The error message for the field. | |
errorMessageProps | Ref<{ 'aria-live': "polite"; 'aria-atomic': boolean; id: string; }> | Props for the error message element. |
errors | The errors for the field. | |
fieldValue | The value of the field. | |
inputEl | Reference 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. |
inputValue | The value of the text field, will contain the label of the selected option or the user input if they are currently typing. | |
isDirty | Whether the field is dirty. | |
isDisabled | Whether the field is disabled. | |
isListEmpty | Whether the listbox is empty, i.e. no options are visible. | |
isPopupOpen | Whether the popup is open. | |
isTouched | Whether the field is touched. | |
isValid | Whether the field is valid. | |
labelProps | Props for the label element. | |
listBoxEl | Reference to the listbox element. | |
listBoxProps | Props 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. |
setTouched | Sets the touched state for the field. | |
setValue | Sets the value for the field. | |
submitErrorMessage | The error message for the field from the last submit attempt. | |
submitErrors | The errors for the field from the last submit attempt. | |
validityDetails | Validity details for the input element. |
useDefaultFilter
Name | Type | Description |
---|---|---|
caseSensitive | ||
debounceMs |