Select
An always-visible listbox: a list of options the user picks from, with arrow-key navigation and type-ahead.
There is no button and no popup. For those, use Listbox, which
wraps a disclosure around the same list.
import { Select, SelectOption } from 'terracotta/select';
import {
SelectStateChild,
SelectOptionStateChild,
useSelectState,
} from 'terracotta/states';Anatomy
<Select> {/* role="listbox", owns the selection */}
<SelectOption/>{/* role="option" */}
</Select>Examples
Single selection
by matters here. The options are objects, and without it two structurally
equal objects would not count as the same value.
Multiple selection
multiple switches value, defaultValue and onChange to arrays.
toggleable lets a second click deselect, which is usually what you want in
multiple mode.
With multiple, the value is an array and clicking an option toggles it.
Showing a tick for selected options
<SelectOption class="select-option select-option-check" value={person}>
{state => (
<>
<span class="select-mark" aria-hidden="true">{state.isSelected() ? '✓' : ''}</span>
{person.name}
</>
)}
</SelectOption>.select-option-check {
display: grid;
grid-template-columns: 1rem 1fr;
align-items: center;
gap: 0.5rem;
}
.select-mark { color: #1d4ed8; }The same mark works without a render prop, driven by the attribute alone:
.select-option::before {
content: "";
display: inline-block;
inline-size: 1rem;
}
.select-option[tc-selected]::before {
content: "✓";
color: #1d4ed8;
}Horizontal
<Select<string> class="select" horizontal defaultValue="day">
<SelectOption class="select-option" value="day">Day</SelectOption>
<SelectOption class="select-option" value="week">Week</SelectOption>
<SelectOption class="select-option" value="month">Month</SelectOption>
</Select>horizontal swaps which arrow keys navigate, and sets aria-orientation. One
CSS rule then keeps layout and semantics in step; see .select[aria-orientation]
above.
Disabled options, and a disabled list
<Select<string> class="select" defaultValue="day">
<SelectOption class="select-option" value="day">Day</SelectOption>
<SelectOption class="select-option" value="week" disabled>
Week (upgrade required)
</SelectOption>
</Select>
<Select<string> class="select" defaultValue="day" disabled>
…
</Select>Both the arrow keys and type-ahead skip disabled options.
Clearing the selection
<Select<string> class="select" toggleable defaultValue="day" onChange={setRange}>
…
</Select>With toggleable, choosing the selected option again clears it, and onChange
receives undefined.
Reading the selection
<Select<string> class="select" defaultValue="day">
{state => (
<>
<SelectOption class="select-option" value="day">Day</SelectOption>
<SelectOption class="select-option" value="week">Week</SelectOption>
<li class="select-hint" aria-hidden="true">
{state.hasSelected() ? 'Range chosen' : 'Pick a range'}
</li>
</>
)}
</Select>From a nested component:
function SelectionCount(): JSX.Element {
const state = useSelectState<Person>();
return <p class="hint">{state.hasSelected() ? 'Selected' : 'Nothing selected'}</p>;
}Grouping options
Navigation ignores non-option elements, so group headings work as-is:
<Select<string> class="select" defaultValue="gb">
<li class="select-group" role="presentation">Europe</li>
<SelectOption class="select-option" value="gb">United Kingdom</SelectOption>
<SelectOption class="select-option" value="fr">France</SelectOption>
<li class="select-group" role="presentation">Asia</li>
<SelectOption class="select-option" value="jp">Japan</SelectOption>
</Select>.select-group {
padding: 0.5rem 0.625rem 0.1875rem;
color: #71717a;
font-size: 0.75rem;
text-transform: uppercase;
}State attributes
| Element | Attribute | Present when |
|---|---|---|
Select |
tc-select |
Always |
Select |
tc-has-selected |
At least one option is selected |
Select |
tc-has-active |
An option currently holds keyboard focus |
Select |
tc-disabled |
The list is disabled |
SelectOption |
tc-select-option, tc-button, tc-owner |
Always |
SelectOption |
tc-selected |
This option is selected |
SelectOption |
tc-active |
This option holds keyboard focus |
SelectOption |
tc-disabled |
This option or the list is disabled |
tc-selected and tc-active mean different things. Selected is the chosen
value. Active is where the keyboard is. While the user browses the list, they
sit on different options.
The option also carries aria-selected. The list carries aria-multiselectable
and aria-orientation.
Styling
[tc-select-option][tc-active] { background: #f4f4f5; }
[tc-select-option][tc-selected] { font-weight: 600; }
[tc-select-option][tc-disabled] { color: #a1a1aa; cursor: not-allowed; }
/* Both at once: the selected option is also the one being browsed */
[tc-select-option][tc-selected][tc-active] { background: #dbeafe; }
/* Container-level state */
[tc-select]:not([tc-has-selected]) .select-hint { display: block; }
[tc-select][tc-has-selected] .select-hint { display: none; }
/* Orientation drives layout */
[tc-select][aria-orientation="horizontal"] { display: flex; gap: 0.25rem; }Reading the state in code
On the list, from the select state:
| Member | Type | Description |
|---|---|---|
isSelected(value) |
(V) => boolean |
Whether that value is selected. |
select(value) |
(V) => void |
Selects it, honouring toggleable. |
hasSelected() |
boolean |
Whether anything is selected. |
isActive(value) / hasActive() |
Keyboard focus position. | |
focus(value) / blur() |
() => void |
Moves or clears the active value. |
disabled() |
boolean |
Whether the list is disabled. |
Inside an option, from the select option state:
| Member | Type | Description |
|---|---|---|
isSelected() |
boolean |
Whether this option is selected. |
isActive() |
boolean |
Whether this option holds keyboard focus. |
select() |
() => void |
Selects this option. |
disabled() |
boolean |
Whether this option is disabled. |
Reach either one through the render prop on the matching component, through
<SelectStateChild> or <SelectOptionStateChild>, or with useSelectState()
or useSelectOptionState().
Keyboard
| Key | Action |
|---|---|
| ↓ / ↑ | Next / previous option, wrapping around (vertical) |
| → / ← | Next / previous option, wrapping around (horizontal) |
| Home / End | First / last option |
| Printable characters | Type-ahead over option text, with a 250 ms window for multi-character prefixes |
| Enter / Space | Selects the focused option; the root suppresses the browser default |
Focusing the list jumps to the selected option, or to the first option when nothing is selected. Disabled options are skipped. Hovering an option focuses it, which is why mouse and keyboard highlight the same way. Moving the pointer off an option clears its highlight but keeps focus where it is.
API
<Select>
Owns a select state and renders a <ul> by default.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'ul' |
Element or component to render as. |
multiple |
boolean |
false |
When true, value / defaultValue / onChange deal in arrays. |
defaultValue |
V | V[] |
none | Initial selection, uncontrolled. Mutually exclusive with value. |
value |
V | V[] |
none | Current selection, controlled. Mutually exclusive with defaultValue. |
onChange |
(value?: V) => void | (value: V[]) => void |
none | Called with the new selection. |
toggleable |
boolean |
false |
Selecting the already-selected value clears (single) or removes (multiple) it. |
disabled |
boolean |
false |
Disables the whole list. |
by |
(a: V, b: V) => boolean |
reference equality | Compares values. Needed for object values. |
horizontal |
boolean |
false |
Lays the list out horizontally: swaps the navigation keys and sets aria-orientation. |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element | (state: SelectStateProperties<V>) => JSX.Element |
none | The options, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
Rendered attributes
| Attribute | Value |
|---|---|
role |
"listbox" |
aria-multiselectable |
Mirrors multiple |
aria-orientation |
"horizontal" or "vertical" |
tabindex |
0, or -1 once an option is active, so focus moves down into the options |
<SelectOption>
One option. A Button with role="option", rendered as an
<li> by default.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'li' |
Element or component to render as. |
value |
V |
required | The value this option represents. |
disabled |
boolean |
false |
Disables this option and removes it from navigation. It is also disabled when the Select is. |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element | (state: SelectOptionStateProperties) => JSX.Element |
none | Label, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
Rendered attributes include role="option", aria-selected, and tabindex,
which is 0 while active and -1 otherwise.
SelectOption throws if rendered outside a <Select>.