Combobox
A combobox is a text input
that filters a popup listbox as you type. Reach for it when a
Listbox has too many options to scroll through.
A Combobox owns two states, as Listbox does. An
autocomplete state holds the query and the
selection, and a disclosure state holds the
popup. That is why its callbacks are onSelectChange and onDisclosureChange.
Navigation is virtual. DOM focus stays on the input while the arrow keys move
an active option, which is published through aria-activedescendant. That is
what lets you keep typing while browsing the list.
Opening the popup activates an option straight away, either the selected one or the first if there is no selection, so the arrow keys start from somewhere sensible.
import {
Combobox,
ComboboxLabel,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
} from 'terracotta/combobox';
import {
DisclosureStateChild,
AutocompleteStateChild,
} from 'terracotta/states';Anatomy
<Combobox matchBy>{/* owns both states */}
<ComboboxLabel/> {/* names the combobox */}
<ComboboxInput/> {/* role="combobox", drives the query */}
<ComboboxOptions>{/* role="listbox" */}
<ComboboxOption/>
</ComboboxOptions>
</Combobox>Filtering is yours to render
matchBy decides which options match, but Terracotta does not remove the rest
from the DOM. An option gets a tc-matches attribute while it matches the
current query, and hiding the others is up to you:
.combobox-option:not([tc-matches]) { display: none; }This is deliberate. Hiding with CSS keeps the options mounted, so the list does not thrash on every keystroke. Either way, keyboard navigation only visits matching, non-disabled options.
Examples
Single selection
tc-active is the only highlight available here. Focus never leaves the input,
so :focus and :hover alone cannot tell you which option the arrow keys are
on.
Options that do not match carry no tc-matches, and the theme hides them. Nothing is unmounted, so the list never thrashes between keystrokes.
Multiple selection
const [selected, setSelected] = createSignal<Person[]>([]);
<Combobox<Person>
class="combobox"
multiple
toggleable
defaultOpen={false}
value={selected()}
onSelectChange={setSelected}
by={(a, b) => a.id === b.id}
matchBy={(person, query) => person.name.toLowerCase().includes(query.toLowerCase())}
>
<ComboboxLabel class="combobox-label">Reviewers</ComboboxLabel>
<ComboboxInput class="combobox-input" placeholder="Add reviewers…" />
<ComboboxOptions class="combobox-options">
<For each={people}>
{person => (
<ComboboxOption class="combobox-option combobox-option-check" value={person}>
<span class="combobox-mark" aria-hidden="true" />
{person.name}
</ComboboxOption>
)}
</For>
</ComboboxOptions>
</Combobox>.combobox-option-check {
display: grid;
grid-template-columns: 1rem 1fr;
align-items: center;
gap: 0.5rem;
}
.combobox-option[tc-selected] .combobox-mark::before {
content: "✓";
color: #1d4ed8;
}In multiple mode the popup stays open after each pick. In single mode it closes.
An empty state
The list's render prop receives the autocomplete state, so you can react to the query:
<ComboboxOptions class="combobox-options">
{state => (
<>
<For each={people}>
{person => (
<ComboboxOption class="combobox-option" value={person}>
{person.name}
</ComboboxOption>
)}
</For>
<Show when={state.hasQuery() && !people.some(p => state.matches(p))}>
<li class="combobox-empty">No people match “{state.query()}”</li>
</Show>
</>
)}
</ComboboxOptions>.combobox-empty {
padding: 0.75rem 0.625rem;
color: #71717a;
text-align: center;
}Highlighting the matched text
<ComboboxOption class="combobox-option" value={person}>
{state => (
<span classList={{ 'combobox-hit': state.matches() }}>{person.name}</span>
)}
</ComboboxOption>Or from CSS alone, with no render prop:
.combobox-option[tc-matches] .combobox-name { color: #18181b; }Controlling the popup
const [open, setOpen] = createSignal(false);
<Combobox<Person>
class="combobox"
isOpen={open()}
onDisclosureChange={setOpen}
value={selected()}
onSelectChange={value => value && setSelected(value)}
matchBy={matchBy}
>
…
</Combobox>Disabled
{/* One option */}
<ComboboxOption class="combobox-option" value={person} disabled={!person.available}>
{person.name}
</ComboboxOption>
{/* The whole combobox */}
<Combobox<Person> class="combobox" disabled value={selected()} matchBy={matchBy}>
…
</Combobox>.combobox[tc-disabled] .combobox-input {
background: #f4f4f5;
cursor: not-allowed;
}With a transition
<Combobox<Person> class="combobox" defaultOpen={false} value={selected()} matchBy={matchBy}>
<ComboboxInput class="combobox-input" />
<DisclosureStateChild>
{({ isOpen }) => (
<Transition
show={isOpen()}
enter="pop-enter" enterFrom="pop-from" enterTo="pop-to"
leave="pop-leave" leaveFrom="pop-to" leaveTo="pop-from"
>
<ComboboxOptions class="combobox-options" unmount={false}>…</ComboboxOptions>
</Transition>
)}
</DisclosureStateChild>
</Combobox>.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.98; }
.pop-to { opacity: 1; scale: 1; }Reading the query elsewhere
<AutocompleteStateChild>
{state => (
<Show when={state.hasQuery()}>
<p class="hint">Filtering by “{state.query()}”</p>
</Show>
)}
</AutocompleteStateChild>Note that query() runs 250 ms behind what has been typed, because it is
debounced.
State attributes
| Element | Attribute | Present when |
|---|---|---|
Combobox |
tc-combobox |
Always |
Combobox |
tc-expanded |
The popup is open |
Combobox |
tc-has-selected |
Something is selected |
Combobox |
tc-has-active |
An option is virtually focused |
Combobox |
tc-disabled |
The combobox is disabled |
ComboboxLabel |
tc-combobox-label |
Always |
ComboboxLabel |
tc-expanded, tc-has-selected, tc-has-active, tc-has-query, tc-disabled |
Mirrors the combobox |
ComboboxInput |
tc-combobox-input |
Always |
ComboboxInput |
tc-expanded, tc-has-selected, tc-has-active, tc-has-query, tc-disabled |
Mirrors the combobox |
ComboboxOptions |
tc-combobox-options |
Always (whenever rendered) |
ComboboxOptions |
tc-expanded, tc-has-selected, tc-has-active, tc-has-query, tc-disabled |
Mirrors the combobox |
ComboboxOption |
tc-combobox-option, tc-button, tc-owner |
Always |
ComboboxOption |
tc-matches |
This option matches the current query |
ComboboxOption |
tc-selected |
This option is selected |
ComboboxOption |
tc-active |
This option is virtually focused |
ComboboxOption |
tc-disabled |
This option or the combobox is disabled |
tc-has-query is the container-level counterpart of tc-matches. It is present
whenever the query is non-empty, which makes "clear" affordances easy.
Styling
/* Filtering */
[tc-combobox-option]:not([tc-matches]) { display: none; }
/* Virtual focus: the only highlight, since DOM focus stays on the input */
[tc-combobox-option][tc-active] { background: #eff6ff; }
[tc-combobox-option][tc-selected] { font-weight: 600; }
[tc-combobox-option][tc-disabled] { color: #a1a1aa; }
/* Popup state on the input */
[tc-combobox-input][tc-expanded] { border-end-start-radius: 0; border-end-end-radius: 0; }
/* Show a clear button only while there is a query */
[tc-combobox]:not([tc-has-query]) .combobox-clear { display: none; }
/* With unmount={false} on the options */
[tc-combobox-options]:not([tc-expanded]) { display: none; }Reading the state in code
Two states are in scope inside a Combobox.
Query and selection: the
autocomplete state, through
<AutocompleteStateChild> or useAutocompleteState():
| Member | Type | Description |
|---|---|---|
query() |
string |
The current query, debounced by 250 ms. |
setQuery(value) |
(string) => void |
Sets the query. |
hasQuery() |
boolean |
Whether the query is non-empty. |
matches(value) |
(V) => boolean |
Runs matchBy against the current query. |
isSelected(value) / hasSelected() |
Selection. | |
isActive(value) / hasActive() |
Virtual focus position. | |
select(value) / focus(value) / blur() |
Changes them. |
The popup: the disclosure state, through
<DisclosureStateChild> or useDisclosureState():
| Member | Type | Description |
|---|---|---|
isOpen() |
boolean |
Whether the popup is open. |
open() / close() / toggle() |
() => void |
Changes it. |
Inside an option, useAutocompleteOptionState() gives the per-option view:
isSelected(), isActive(), matches(), select() and disabled().
Keyboard
All of this is handled on ComboboxInput, since focus never leaves it:
| Key | Action |
|---|---|
| Typing | Updates the query (debounced 250 ms) and opens the popup |
| ↓ | Opens the popup, or moves to the next matching option |
| ↑ | Opens the popup, or moves to the previous matching option |
| Enter | Selects the active option while the popup is open. While it is closed, the key is left alone so a form can submit |
| Escape | Closes the popup. While it is closed, the key is passed on, so a surrounding Dialog can close |
aria-activedescendant is removed while the popup is closed and while no option
matches the query.
Clicking the input toggles the popup. Blurring the input closes it, unless the pointer is over the options list. That exception is what stops a click on an option from closing the popup before the click lands.
API
<Combobox>
Renders a <div> by default. Does not take a ref.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'div' |
Element or component to render as. |
matchBy |
(value: V, query: string) => boolean |
required | Decides whether an option matches the current query. |
multiple |
boolean |
false |
When true, the value is an array and the popup stays open after each pick. |
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 combobox. |
by |
(a: V, b: V) => boolean |
reference equality | Compares values. Needed for object values. |
children |
JSX.Element |
none | The label, input and options. Not a render prop. |
| …rest | props of as |
none | Forwarded to the rendered element. |
Combobox has no onChange. Use onSelectChange and onDisclosureChange.
There is no horizontal option either, because the list is always vertical.
<ComboboxLabel>
Names the input and the popup list through aria-labelledby. Both drop the
attribute when no ComboboxLabel is rendered, so name them yourself with
aria-label in that case. Renders a <label> by default. It takes no ref,
and its children is not a render prop.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'label' |
Element or component to render as. |
children |
JSX.Element |
none | Label text. |
| …rest | props of as |
none | Forwarded to the rendered element. |
<ComboboxInput>
The text field. Renders an <input type="text"> by default. Everything you pass
is forwarded, including placeholder, value, onInput and class, so you
control what the input displays.
| Prop | Type | Default | Description |
|---|---|---|---|
as |
ValidConstructor |
'input' |
Element or component to render as. Query tracking only works on a real <input>. |
disabled |
boolean |
false |
Disables the input's own handling. It is also disabled when the Combobox is. |
ref |
DynamicNode<T> | (el) => void |
none | Handle to the rendered element. |
children |
JSX.Element |
none | Rarely used for an input. |
| …rest | props of as |
none | Forwarded to the rendered element. |
Rendered attributes include role="combobox", type="text", tabindex="0",
aria-haspopup="listbox", aria-autocomplete="list", aria-controls,
aria-labelledby, aria-expanded and aria-activedescendant.
<ComboboxOptions>
The popup list. Renders a <ul> by default and is kept out of the tab order, so
focus stays on the input.
| 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: AutocompleteStateProperties<V>) => JSX.Element |
none | The options, or a render prop receiving the autocomplete state. |
| …rest | props of as |
none | Forwarded to the rendered element. |
Rendered attributes include role="listbox", aria-multiselectable,
aria-labelledby, aria-orientation="vertical" and tabindex="-1".
<ComboboxOption>
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; also what matchBy receives. |
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: AutocompleteOptionStateProperties) => 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, tabindex="-1",
and a generated id, the one aria-activedescendant points at.
Every descendant throws if rendered outside a <Combobox>.