Developer Experience (DX)
Next, let's look at how developers use the Select. You give it a list of options and a name, put it in a Field, and bind its value only when your code needs to read it.
import { useState } from "react";
import { Field } from "@/components/Field";
import { Select } from "@/components/Select";
const countries = [
{ value: "fr", label: "France" },
{ value: "es", label: "Spain" },
{ value: "nz", label: "New Zealand" },
];
export function Shipping() {
const [country, setCountry] = useState<string>();
return (
<Field label="Country">
<Select id="country" name="country" options={countries} placeholder="Choose a country" value={country} onChange={(event) => setCountry(event.target.value)} />
</Field>
);
}
Consistent
The Select uses the names the other form controls use, so knowing one means knowing them all: the same size scale as the Input, disabled, readonly, required, placeholder and name, and aria-invalid rather than an invalid prop. Like the Input, classes and styles go on the root, which draws the field, and everything else on the control, including id and ARIA. Unlike the Input, the Select puts name, form and autocomplete on the <select> in both renderings, where change and input fire too, since that's what forms read.
| Name | Kind | What it means |
|---|---|---|
size | Prop | xs, sm, md, lg or xl, as on the Input |
disabled | Prop | The Select can't be used, and leaves its form |
readonly | Prop | The value shows and submits, but can't change |
required | Prop | The form needs a value, which the Field sets |
placeholder | Prop | The text shown while nothing is picked |
name | Prop | The name the value submits under |
leading | Slot | An icon before the value |
change | Event | The value changed |
nativeOnTouch is the one name the other controls don't have, so it says when it applies and what it does. Like the Popover's arrow, it's on by default, and you turn it off with false.
Type-safe
The props, the options, the value and every slot are typed, so your editor can autocomplete them and your type checker catches mistakes like size="medium".
| Prop | Type | Default | Description |
|---|---|---|---|
options | SelectItem[] | Required. The options, groups and separators people pick from | |
value | string | string[] | The value, bound or not, as an array with multiple | |
multiple | boolean | false | Picks any number of values, each shown as a tag |
placeholder | string | Shown while nothing is picked, and never a value | |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | How big the Select is |
disabled | boolean | false | Sets disabled on both controls, and leaves its form |
readonly | boolean | false | Keeps the value in its form, and turns off nativeOnTouch |
required | boolean | false | Asks for a value, and the Field sets it |
nativeOnTouch | boolean | true | Shows the native <select> on a coarse primary pointer |
removeLabel | (label: string) => string | `Remove ${label}` | A tag's remove button's name |
removedLabel | (label: string) => string | `${label} removed` | What screen readers hear after a removal |
countLabel | (count: number) => string | `${count} selected` | The value read out past three |
| Event | Payload | Description |
|---|---|---|
change | Event | Fires from the <select>, and bubbles, once a pick in either rendering changes the value |
input | Event | Fires from the <select>, and bubbles, right before change |
We also export the types for data-driven forms: SelectOption, SelectGroup, SelectSeparator, SelectItem and SelectProps.
import type { SelectItem, SelectOption, SelectProps } from "@/components/Select";
const items: SelectItem[] = [
{ value: "fr", label: "France" },
{ separator: true },
{ label: "Oceania", options: [{ value: "nz", label: "New Zealand" }] },
];
An item with a value is an option, one with options is a group, and one with separator is a separator. A few names differ by framework, each for that framework's reason. React and Solid pass the value slot as renderValue, since value is already the value, and group-label as groupLabel. Angular puts id and name on the <select> inside its directive, so the label's for finds a real control, and uses (ngModel) for the value.
| Slot | Receives | Native rendering |
|---|---|---|
leading | Nothing | Honoured |
value | selected, the picked options | Not honoured |
arrow | Nothing | Honoured |
option | option, selected and active | Not honoured |
tag | option | Not honoured |
group-label | label | Not honoured |
separator | Nothing | Not honoured |
icon | option, shown in the panel, the field and tags | Not honoured |
Composable
Put the Select in a Field, and it gets its label, hint, error, required, disabled and size without any wiring. It takes the same text-control path through the Field as an Input does, in both renderings, since it's the same component either way.
The slots let you change what's inside each part without giving up how it behaves. The leading, arrow and separator slots take content, while value, option, tag, group-label and icon hand you what to render, as the slot table shows. The option slot keeps the check mark beside what you render, and the tag slot keeps the remove button.
<Select id="country" name="country" options={countries} nativeOnTouch={false}
leading={<GlobeIcon />}
renderValue={({ selected }) => <>Ship to {selected[0]?.label}</>}
option={({ option, selected }) => <>{option.label}{selected && " (yours)"}</>}
/>
Controllable
Bind the value when your code reads it, and leave it unbound when the form is all you need. The <select> holds it either way, so an unbound Select still submits and resets.
// Controlled: your state decides.
<Select id="country" name="country" options={countries} value={country} onChange={(event) => setCountry(event.target.value)} />
// Uncontrolled: the Select keeps its own, and its form submits it.
<Select id="country" name="country" options={countries} defaultValue="fr" />
change and input fire from the <select>, so a listener on the Select or on its form hears both a picker choice and a selection in the panel, with the value already updated. A ref gets you both controls, through control and select, for focusing it from your own code.
DX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
select/options-prop | Must | Component | Options, groups and separators are one typed options list, the same in both renderings | Open Components | Unit test, Type check |
select/value | Must | Component | The value is a string, or an array with multiple, bound or unbound | Open Components | Unit test |
select/typed | Should | Component | Its props and slots are typed, and the option types are exported | Open Components | Type check |
select/same-names | Should | Component | It uses the names and sizes the other form controls use | Open Components | Review |
select/attributes | Must | Component | Classes and styles go on the root, form attributes on the <select>, and the rest on the active control | Open Components | Unit test |
select/in-a-field | Must | Both | In a Field, it takes the label, description, invalid and disabled state in both renderings | WCAG 1.3.1 (A) | Unit test |
select/slots | Should | Component | Each part has a slot or tokens to restyle it, and the check mark and remove button stay | Open Components | Unit test |
select/translatable | Should | Component | Every string it announces or adds comes through a prop | Open Components | Unit test |