Listbox
A collapsible listbox is
the custom equivalent of a <select>: a button that shows the current value,
plus a popup list of options.
Listbox is unusual, because it owns two states at once. A
disclosure state drives the popup, and a
select state drives the value. That is why its
change callbacks are named onDisclosureChange and onSelectChange instead of
a single onChange, and why it can be controlled on one axis and uncontrolled
on the other.
If you do not need the popup, use Select instead.
import {
Listbox,
ListboxButton,
ListboxLabel,
ListboxOptions,
ListboxOption,
} from 'terracotta/listbox';
import { DisclosureStateChild, SelectStateChild } from 'terracotta/states';Anatomy
<Listbox> {/* owns both states */}
<ListboxLabel/> {/* names the listbox */}
<ListboxButton/> {/* toggles the popup, shows the value */}
<ListboxOptions> {/* role="listbox", keyboard navigation */}
<ListboxOption/>
</ListboxOptions>
</Listbox>Examples
Single selection
Selecting an option closes the popup. With multiple set, the popup stays open
so several values can be picked.
Multiple selection
The popup stays open while you pick, because selecting is not the same event as closing.
Controlling the popup too
Each state can be controlled independently:
const [open, setOpen] = createSignal(false);
const [value, setValue] = createSignal<string>();
<Listbox<string>
class="listbox"
isOpen={open()}
onDisclosureChange={setOpen}
value={value()}
onSelectChange={setValue}
>
…
</Listbox>Mixing them is fine. An uncontrolled popup with a controlled value is the most common combination.
Horizontal options
<Listbox<string> class="listbox" horizontal defaultOpen={false} defaultValue="day">
…
</Listbox>.listbox-options[aria-orientation="horizontal"] {
display: flex;
gap: 0.25rem;
}Disabled
{/* One option */}
<ListboxOption class="listbox-option" value={person} disabled>
{person.name}
</ListboxOption>
{/* The whole listbox */}
<Listbox<Person> class="listbox" defaultOpen={false} defaultValue={people[0]} disabled>
…
</Listbox>.listbox[tc-disabled] .listbox-button {
opacity: 0.5;
cursor: not-allowed;
}With a transition
The popup state is a disclosure state, so <DisclosureStateChild> hands you the
show flag:
<Listbox<string> class="listbox" defaultOpen={false} defaultValue="day">
<ListboxButton class="listbox-button">Range</ListboxButton>
<DisclosureStateChild>
{({ isOpen }) => (
<Transition
show={isOpen()}
enter="pop-enter" enterFrom="pop-from" enterTo="pop-to"
leave="pop-leave" leaveFrom="pop-to" leaveTo="pop-from"
>
<ListboxOptions class="listbox-options" unmount={false}>…</ListboxOptions>
</Transition>
)}
</DisclosureStateChild>
</Listbox>.pop-enter { transition: opacity 120ms ease-out, scale 120ms ease-out; }
.pop-leave { transition: opacity 80ms ease-in, scale 80ms ease-in; }
.pop-from { opacity: 0; scale: 0.97; }
.pop-to { opacity: 1; scale: 1; }Reading the selection outside the button
<Listbox<Person> class="listbox" defaultOpen={false} value={selected()}>
…
<SelectStateChild>
{state => (
<p class="hint">{state.hasSelected() ? 'Assigned' : 'Unassigned'}</p>
)}
</SelectStateChild>
</Listbox>State attributes
| Element | Attribute | Present when |
|---|---|---|
Listbox |
tc-listbox |
Always |
Listbox |
tc-expanded |
The popup is open |
Listbox |
tc-has-selected |
Something is selected |
Listbox |
tc-has-active |
An option holds keyboard focus |
Listbox |
tc-disabled |
The listbox is disabled |
ListboxLabel |
tc-listbox-label |
Always |
ListboxLabel |
tc-expanded, tc-has-selected, tc-has-active, tc-disabled |
Mirrors the listbox |
ListboxButton |
tc-listbox-button, tc-button |
Always |
ListboxButton |
tc-expanded, tc-has-selected, tc-has-active, tc-disabled |
Mirrors the listbox |
ListboxOptions |
tc-listbox-options |
Always (whenever rendered) |
ListboxOptions |
tc-expanded, tc-has-selected, tc-has-active, tc-disabled |
Mirrors the listbox |
ListboxOption |
tc-listbox-option, tc-button, tc-owner |
Always |
ListboxOption |
tc-selected |
This option is selected |
ListboxOption |
tc-active |
This option holds keyboard focus |
ListboxOption |
tc-disabled |
This option or the listbox is disabled |
Both states are published on every part. That is what makes the caret rotation and the placeholder dimming above work without touching a signal.
Styling
/* Popup state */
[tc-listbox-button][tc-expanded] { border-color: #2563eb; }
[tc-listbox-options]:not([tc-expanded]) { display: none; } /* with unmount={false} */
/* Selection state on the container */
[tc-listbox-button]:not([tc-has-selected]) .listbox-value { color: #a1a1aa; }
/* Per-option state */
[tc-listbox-option][tc-active] { background: #eff6ff; }
[tc-listbox-option][tc-selected] { font-weight: 600; }
[tc-listbox-option][tc-disabled] { color: #a1a1aa; }
/* Orientation drives layout */
[tc-listbox-options][aria-orientation="horizontal"] { display: flex; }Reading the state in code
Two states are in scope inside a Listbox.
The popup, from the disclosure state, via
<DisclosureStateChild> or useDisclosureState():
| Member | Type | Description |
|---|---|---|
isOpen() |
boolean |
Whether the popup is open. |
open() / close() / toggle() |
() => void |
Changes it. |
The selection, from the select state, via
<SelectStateChild> or useSelectState():
| Member | Type | Description |
|---|---|---|
isSelected(value) |
(V) => boolean |
Whether that value is selected. |
hasSelected() |
boolean |
Whether anything is selected. |
select(value) |
(V) => void |
Selects it. |
isActive(value) / hasActive() |
Keyboard focus position. |
Inside an option, useSelectOptionState() gives the per-option view:
isSelected(), isActive(), select() and disabled().
Keyboard
On ListboxButton:
| Key | Action |
|---|---|
| Enter / Space | Toggles the popup |
| ↑ / ↓ | Toggles the popup |
On ListboxOptions:
| Key | Action |
|---|---|
| ↓ / ↑ | Next / previous option, wrapping around (vertical) |
| → / ← | Next / previous option, wrapping around (horizontal) |
| Home / End | First / last option |
| Escape | Closes the popup |
| Printable characters | Type-ahead over option text, 250 ms window |
| Enter / Space | Selects the focused option |
When the popup opens, focus lands on the selected option, or on the first option when nothing is selected. Moving focus out of the options closes the popup, unless the pointer is hovering the button or the list.
API
<Listbox>
Renders a <div> by default. Does not take a ref.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'div' |
Element or component to render as. |
multiple |
boolean |
false |
When true, the value is an array and picking an option keeps the popup open. |
defaultValue |
V | V[] |
none | Initial selection, uncontrolled. Mutually exclusive with value. |
value |
V | V[] |
none | Current selection, controlled. Mutually exclusive with defaultValue. |
onSelectChange |
(value?: V) => void | (value: V[]) => void |
none | Called with the new selection. |
defaultOpen |
boolean |
none | Initial popup state, uncontrolled. Mutually exclusive with isOpen. |
isOpen |
boolean |
none | Current popup state, controlled. Mutually exclusive with defaultOpen. |
onDisclosureChange |
(value: boolean) => void |
none | Called when the popup opens or closes. |
onOpen |
() => void |
none | Called when the popup opens. |
onClose |
() => void |
none | Called when the popup closes. |
toggleable |
boolean |
false |
Selecting the already-selected value clears or removes it. |
disabled |
boolean |
false |
Disables the whole listbox. |
by |
(a: V, b: V) => boolean |
reference equality | Compares values. Needed for object values. |
horizontal |
boolean |
false |
Lays the options out horizontally: swaps the navigation keys and sets aria-orientation. |
children |
JSX.Element |
none | The label, button and options. Not a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
Listbox has no onChange. Use onSelectChange and onDisclosureChange.
<ListboxLabel>
Names the button and the popup list through aria-labelledby. Renders a
<label> by default. Does not take a ref.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'label' |
Element or component to render as. |
children |
JSX.Element | (state: DisclosureStateProperties) => JSX.Element |
none | Label text, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
<ListboxButton>
A Button that toggles the popup. Renders a <button> by
default.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'button' |
Element or component to render as. |
disabled |
boolean |
false |
Disables this button. It is also disabled when the Listbox is. |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element | (state: DisclosureStateProperties) => JSX.Element |
none | Usually the current value, or a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
Rendered attributes include aria-haspopup="listbox", aria-controls,
aria-expanded and aria-labelledby. The button's name is the label followed
by its own text, so a screen reader hears both the label and the current value.
Without a ListboxLabel, the button has no aria-labelledby.
<ListboxOptions>
The popup list. Renders a <ul> by default.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'ul' |
Element or component to render as. |
unmount |
boolean | 'offscreen' |
true |
How the list behaves while closed. See unmount. |
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 receiving the select state. |
| …rest | props of as |
none | Forwarded to the rendered element. |
Rendered attributes include role="listbox", aria-multiselectable,
aria-labelledby and aria-orientation. The list is named by the
ListboxLabel, or by the button when there is no label.
<ListboxOption>
One option. A Button with role="option", rendered as an
<li> by default and kept out of the tab order.
| 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. |
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. |
Every descendant throws if rendered outside its required ancestor.
ListboxOption needs a ListboxOptions around it, not only a Listbox.