Skip to content

Select Fields

Select fields are very common form fields. They allow the user to select one or more options from a list of options.

The native select element does a lot in terms of interactivity and accessibility. However, it leaves a lot to be desired in terms of styling and customization. Formwerk tries to address that by providing the same accessability and interactive behaviors to your custom select component so you don’t have to compromise for the sake of styling.

Note that customizable selects are coming soon to the browser and Formwerk will leverage that when it becomes available.

Features

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

When focusing the trigger element, and menu is closed:

KeyDescription
Space
Opens the options menu.
Enter
Opens the options menu.
ArrowDown
Opens the options menu.
ArrowUp
Opens the options menu.

When focusing an option in the menu, and menu is open:

KeyDescription
Space
Selects the option, if multiple toggles the option selection state.
Enter
Selects the option, if multiple toggles the option selection state.
Arrow Down
Focuses the next option if available.
Arrow Up
Focuses the previous option if available.
Shift
+ Arrow Down
Focuses the next option and selects it.
Shift
+ Arrow Up
Focuses the previous option and selects it.
Home
Focuses the first option.
Shift
+ Home
Focuses the first option. If multiple is enabled, selects all options between the last focused option and the first option.
Page Up
Focuses the first option.
Shift
+ Page Up
Focuses the first option. If multiple is enabled, selects all options between the last focused option and the first option.
End
Focuses the last option.
Shift
+ End
Focuses the last option. If multiple is enabled, selects all options between the last focused option and the last option.
Page Down
Focuses the last option.
Shift
+ Page Down
Focuses the last option. If multiple is enabled, selects all options between the last focused option and the last option.
Command
+ A
If multiple is enabled, selects all options. If all are already selected, deselects all options.

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
Value
Selected value
Trigger
Help text
Description or Error Message
Label
Value
Option 1
Option 2
Option item
Option 3
Popup

Building a Select Field

The select 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 select field is made up of the following parts:

  • Trigger: A trigger element that is used to open the options menu, doubles as the selected value display.
  • Popup: 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:

  • Select: Contains the trigger and popup parts. You will use useSelect 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 popup 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 popup 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.

Building a Select Component

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

The useSelect 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 SelectField from './SelectField.vue';
import OptionItem from './OptionItem.vue';
</script>
<template>
<SelectField label="Select a drink">
<OptionItem label="Coffee ☕️" value="coffee" />
<OptionItem label="Tea 🍵" value="tea" />
<OptionItem label="Milk 🥛" value="milk" />
</SelectField>
</template>
<script setup lang="ts">
import { useSelect, type SelectProps } from '@formwerk/core';
const props = defineProps<SelectProps>();
const {
triggerProps,
labelProps,
errorMessageProps,
isTouched,
errorMessage,
fieldValue,
popupProps,
} = useSelect(props);
</script>
<template>
<div class="select">
<div v-bind="labelProps" class="select-label">{{ label }}</div>
<div v-bind="triggerProps" class="trigger">
<div v-if="multiple && fieldValue?.length" class="multi-value-display">
<span v-for="selected in fieldValue">{{ selected }}</span>
</div>
<span v-else class="placeholder">
{{ fieldValue?.length ? fieldValue : 'Pick a value' }}
</span>
<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>
</div>
<div v-bind="popupProps" popover class="listbox">
<slot />
</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>
148 collapsed lines
<style scoped>
.select {
--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;
.select-label {
color: var(--color-text);
display: block;
margin-bottom: 0.25rem;
font-size: 14px;
font-weight: 500;
}
.trigger {
display: flex;
align-items: center;
justify-content: space-between;
/** CSS Anchor Positioning */
anchor-name: --trigger;
border: 1px solid var(--color-border);
padding: 4px 8px;
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);
}
}
.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: --trigger;
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;
}
}
.multi-value-display {
display: flex;
gap: 3px;
span {
padding: 2px 4px;
background: #10b981;
font-size: 13px;
color: #fff;
border-radius: 4px;
}
}
.placeholder {
color: var(--color-hint);
}
}
@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 SelectField from './SelectField.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>
<SelectField label="Select a country">
<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>
</SelectField>
</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>

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

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

<script setup lang="ts">
import SelectField from './SelectField.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>
<SelectField label="Select a drink" :schema="schema">
<OptionItem label="Coffee ☕️" value="coffee" />
<OptionItem label="Tea 🍵" value="tea" />
<OptionItem label="Milk 🥛" value="milk" />
</SelectField>
</template>

Usage

Multiple Select

The useSelect composable accepts a multiple prop, it adjusts behaviors to what users expect out of a multi-select field.

<script setup lang="ts">
import SelectField from './SelectField.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>
<SelectField label="Pick countries" multiple>
<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>
</SelectField>
</template>

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 SelectField from './SelectField.vue';
import OptionGroup from './OptionGroup.vue';
import OptionItem from './OptionItem.vue';
</script>
<template>
<SelectField label="Pick a drink" disabled>
<OptionItem label="Coffee ☕️" value="coffee" />
<OptionItem label="Tea 🍵" value="tea" />
<OptionItem label="Milk 🥛" value="milk" />
</SelectField>
</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 SelectField from './SelectField.vue';
import OptionGroup from './OptionGroup.vue';
import OptionItem from './OptionItem.vue';
</script>
<template>
<SelectField label="Pick a drink">
<OptionItem label="Coffee ☕️" value="coffee" />
<OptionItem label="Tea 🍵" disabled value="tea" />
<OptionItem label="Milk 🥛" value="milk" />
</SelectField>
</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 SelectField from './SelectField.vue';
import OptionGroup from './OptionGroup.vue';
import OptionItem from './OptionItem.vue';
</script>
<template>
<SelectField label="Pick a drink" readonly>
<OptionItem label="Coffee ☕️" value="coffee" />
<OptionItem label="Tea 🍵" value="tea" />
<OptionItem label="Milk 🥛" value="milk" />
</SelectField>
</template>

Styling

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

Option attributes

When an option is selected, it will receive the aria-selected="true" attribute if the select field is a single choice select.

If the select field is a multi-choice select, the selected options will receive the aria-checked="true" attribute instead.

Trigger attributes

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

Popover Attributes

The popover (listbox) element will receive the aria-activedescendant attribute when an option is focused that corresponds to the id of the focused option.

Furthermore, the popover element will receive the aria-multiselectable attribute if the select field is a multi-choice select.

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.

Select

Props

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

NameTypeDescription
descriptionDescription text for the select field.
disabledWhether the select field is disabled.
labelThe label text for the select field.
modelValueThe v-model value of the select field.
multipleWhether multiple options can be selected.
nameThe name of the select field.
orientationThe orientation of the listbox popup (vertical or horizontal).
placeholderPlaceholder text when no option is selected.
readonlyWhether the select field is readonly.
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
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.
isDirtyWhether the field is dirty.
isDisabledWhether the field is disabled.
isPopupOpenWhether the popup is open.
isTouchedWhether the field is touched.
isValidWhether the field is valid.
labelPropsProps for the label element.
listBoxElReference to the popup element.
popupPropsProps for the popup element.
selectedOption
Ref<{ id: string; value: TValue; label: string; }>
The currently selected option.
selectedOptionsThe currently selected options.
setErrors
(messages: Arrayable<string>) => void
Sets the errors for the field.
setTouchedSets the touched state for the field.
setValueSets the value for the field.
triggerPropsProps for the trigger element.