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:
Key | Description |
---|---|
⎵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:
Key | Description |
---|---|
⎵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
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.
- 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:
- Select: Contains the trigger and listbox 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 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 listbox 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.
Building a Select Component
Next, you will use useSelect
to create our select component. This component will be responsible for rendering the trigger and listbox 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 { useId } from 'vue';import { useSelect, type SelectProps } from '@formwerk/core';
const props = defineProps<SelectProps>();const id = useId();const triggerId = `--trigger-${id}`;
const { triggerProps, labelProps, errorMessageProps, isTouched, errorMessage, fieldValue, listBoxProps,} = 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="listBoxProps" 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: v-bind('triggerId'); 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: v-bind('triggerId'); 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>
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>
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.
It will also receive the aria-activedescendant
attribute when an option is focused.
ListBox attributes
The listbox 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.
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. |
Select
Props
These are the properties that can be passed to the useSelect
composable.
Name | Type | Description |
---|---|---|
description | Description text for the select field. | |
disabled | Whether the select field is disabled. | |
label | The label text for the select field. | |
modelValue | The v-model value of the select field. | |
multiple | Whether multiple options can be selected. | |
name | The name of the select field. | |
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. | |
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 |
---|---|---|
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. | |
isDirty | Whether the field is dirty. | |
isDisabled | Whether the field is disabled. | |
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 popup element. | |
listBoxProps | Props for the listbox/popup element. | |
selectedOption | Ref<{ id: string; value: TValue; label: string; }> | The currently selected option. |
selectedOptions | The currently selected options. | |
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. | |
triggerProps | Props for the trigger element. |