Select
Two shapes of the same idea. SelectInput is a field whose own text is the search. InlineSelect is a small one that lives inside another field.
<SelectInput
label="Country"
options={[
{ value: 'fi', label: 'Finland' },
{ value: 'se', label: 'Sweden' },
]}
hint="Type to filter. The field is the search."
/>The field is the search
The country picker in the phone field puts a search box inside its panel, because the thing you clicked was a flag and a dial code, not somewhere to type. Here the thing you clicked is already a text field, so it filters. A second text box below the first would only be asking which one you meant.
value is the option's value, never the label on screen. Those are different things, and you should not have to parse a label back into an id. What you type while the panel is open is the panel's business and is discarded when it closes.
const [country, setCountry] = useState('fi')
<SelectInput
label="Country"
value={country}
onChange={setCountry}
options={COUNTRIES}
/>Inline selector
A second decision attached to the value beside it: an access level on a name, a unit on a number. It goes in another field's trailing slot, which is why it has no label of its own and why aria-label is not optional.
<Input
label="Share with"
trailing={<InlineSelect aria-label="Access level" options={ACCESS} />}
/>Keyboard
Both answer to the same keys as the country picker, because they are the same popover: arrows with wrap, Home and End, Enter to choose, Escape to close. The highlighted option is tracked with aria-activedescendant rather than by moving focus, so the field keeps it and typing never breaks.
SelectInput props
| Prop | Type | Default | Notes |
|---|---|---|---|
options | SelectOption[] | — | { value, label, keywords? }. `keywords` is matched but never shown. |
leading | ReactNode | — | A mark at the start of the field. |
emptyLabel | ReactNode | "No match" | Shown when the query matches nothing. |
required | boolean | false | Renders the asterisk and sets aria-required. |
optional | boolean | false | Renders the muted "(Optional)" note. |
info | string | — | Info marker beside the label, with this as its tooltip. |
Shared
| Prop | Type | Default | Notes |
|---|---|---|---|
value | string | — | Controlled value. |
defaultValue | string | — | Initial value when uncontrolled. |
onChange | (value: string) => void | — | Receives the value, never the event. |
label | ReactNode | — | Rendered as a real <label> wired by htmlFor. |
hint | ReactNode | — | Helper text under the field. |
error | ReactNode | — | Error text. Implies invalid unless invalid says otherwise. |
invalid | boolean | — | Forces the error styling on or off. |
errorKey | string | number | — | Change it to replay the error animation. |
disabled | boolean | false | Greys the field out. |
readOnly | boolean | false | Reads as filled, stays focusable. |
size | 'sm' | 'md' | 'lg' | 'md' | Size scale, where the component defines one. |
classNames | Record<Part, string> | — | Per-part class names. |
InlineSelect props
Not a field, so it does not take the shared contract. It has no label, hint, error or size.
| Prop | Type | Default | Notes |
|---|---|---|---|
options | SelectOption[] | — | The choices. |
value | string | — | Selected value, controlled. |
defaultValue | string | first option | Selected value, uncontrolled. |
onChange | (value: string) => void | — | Fires on selection. |
aria-label | string | — | Required: it has no visible label of its own. |