Appearance
Combobox
A value chosen by typing. Reach for it when the list is one a person searches rather than reads: forty people to assign, two hundred labels. Typing narrows the list to the options that match, best first, and the highlight walks the matches while the caret stays in the field.
When the list is short enough to read, a select is better: it shows every answer the moment it opens and needs no typing.
tsx
/** Declared once: a fresh array per frame rebuilds every row. */
const PEOPLE: readonly ComboboxOption[] = [
{ value: 'ada', label: 'Ada Okafor', detail: '@ada' },
{ value: 'grace', label: 'Grace Hopper', detail: '@grace' },
{ value: 'alan', label: 'Alan Turing', detail: 'on leave', disabled: true },
{ value: 'kim', label: 'Kim Lee', detail: '@kim', keywords: ['design'] },
{ value: 'linus', label: 'Linus Park', detail: '@linus', keywords: ['infra'] }
];
const LABELS: readonly ComboboxOption[] = [
{ value: 'bug', label: 'Bug' },
{ value: 'feature', label: 'Feature' },
{ value: 'perf', label: 'Performance', keywords: ['slow', 'speed'] },
{ value: 'a11y', label: 'Accessibility', keywords: ['screen reader'] },
{ value: 'docs', label: 'Documentation' }
];
/**
* An issue's assignee and labels, the two lists a person searches
* rather than reads.
*
* **Assignee is one value.** Type part of a name, or a keyword (`design`
* finds Kim), and Enter takes the highlighted match. Alan is on leave,
* so the arrows step over him. Escape closes the list; a second Escape
* puts back the name that's chosen.
*
* **Labels are several.** Each choice toggles a label and the list stays
* open for the next; Backspace in the empty field takes the last one
* off, and each chosen label has its own remove button.
*/
export function AssignIssue(_inputs: Inputs<{}>, _ctx: ComponentContext) {
const assignee = internalState('ada');
const labels = internalState<readonly string[]>(['bug']);
return (
<column gap={14} padding={20} width={percent(100)} height={percent(100)}>
<Combobox
label="Assignee"
placeholder="Unassigned"
options={PEOPLE}
value={assignee}
onChange={next => (assignee.value = next)}
/>
<Combobox
label="Labels"
placeholder="Add a label"
multiple
options={LABELS}
values={labels}
onValuesChange={next => (labels.value = next)}
/>
<text
text={labels.pipe(map(chosen => `${chosen.length} label${chosen.length === 1 ? '' : 's'}`))}
fontSize={12}
color="textMuted"
/>
</column>
);
}Tab into Assignee and type design: Kim is found by a keyword, and Enter assigns her. Alan is disabled, so the arrows step over him. In Labels, each Enter toggles a label and the list stays open for the next one; Backspace in the empty field takes the last one off.
Props
| Prop | Type | Default | What it does |
|---|---|---|---|
options | readonly ComboboxOption[] | required | The options. Declare the array once. |
value | string | none | The chosen value, '' for none, when the application owns it. |
defaultValue | string | none | The value to start on, when the combobox owns it. |
onChange | (value: string) => void | none | Called with the value chosen. |
multiple | boolean | false | Several values: each choice toggles one, and the list stays open. |
values | readonly string[] | none | The chosen values in multiple, when the application owns them. |
defaultValues | readonly string[] | none | The values to start on in multiple, when the combobox owns them. |
onValuesChange | (values: readonly string[]) => void | none | Called with the values after a choice or a removal. |
label | string | '' | Drawn above the field, and the name of the field and the list. |
labelHidden | boolean | false | The label still names the field but isn't drawn. |
placeholder | string | '' | Drawn in the empty field. |
description | string | '' | Shown under the field and read after its name. |
error | string | '' | What's wrong, shown in place of the description; implies invalid. |
invalid | boolean | false | Draws the border in danger and adds the invalid state. |
required | boolean | false | Adds the required state. It enforces nothing. |
disabled | boolean | false | Refuses typing and presses, and greys the field. |
emptyText | string | 'No matches' | Shown in the list when nothing matches. |
onQueryChange | (query: string) => void | none | Called with the text as it's typed, for a list searched somewhere else. |
filter | boolean | true | Filters options by what's typed. false shows them as given, already narrowed by a search. |
listHeight | number | 280 | The list's tallest before it scrolls. The highlight is kept in view as it moves. |
ref | UiNodeRef | none | Receives the field, for focusing it or anchoring something to it. |
A ComboboxOption is a value and a label, plus an optional detail drawn quieter beside the label (a handle, a team), keywords it also answers to, and disabled. A label longer than the list is wide is cut short with an ellipsis inside the list, so it never runs past the edge or pushes the chosen option's tick out of its column.
The list opens under the field, as wide as it is but never narrower than 180 pixels, lined up with the field's start edge: its left in left-to-right text and its right under textDirection="rtl", so a list wider than a narrow field grows the way the field's text runs. It flips above the field when there is no room below.
Matching
What was typed is matched against each label, case-insensitively, and ranked: a label that starts with it, then one with a word that starts with it, then one that contains it anywhere, then an option with a keyword that contains it. Within a rank the options keep their own order. filterCombobox(options, query) is the same function, exported for a caller that wants to show a count or test its own options.
A single combobox showing its own value's label lists everything, since the label is what's chosen, not a search for it. Focusing the field selects that label, so the first character typed starts a new search.
Searching somewhere else
A list too long to hand the component, such as every issue in a workspace held by a data worker, is searched where it lives. Pass onQueryChange to hear the text as it's typed, send it to the search, and pass what comes back as options with filter={false}, so the combobox shows the results as they are rather than filtering them again:
tsx
<Combobox
label="Parent issue"
filter={false}
options={found}
onQueryChange={query => search.send.find(query)}
onChange={key => search.send.setParent(key)}
/>The label shown for a chosen value is looked up in the current options, so a search that moves on can leave the field blank; a caller that keeps a value shown keeps its option in the list.
Keyboard
The field is one tab stop, and focus never leaves it: the list is walked by a highlight, which the field names as its activeDescendant, so a screen reader announces each option as the highlight reaches it. While the list is open the field controls it, and the field's autocomplete is list, so the platform knows it suggests and which list holds the suggestions.
| Key | What it does |
|---|---|
| Typing | Filters the list, opening it |
Down | Opens the list, or moves to the next option that can be chosen |
Up | Opens the list, or moves to the previous one |
Enter | Chooses the highlighted option. Single closes; multiple toggles and stays |
Escape | Closes the list. With it closed, puts back the chosen label |
Backspace | In multiple, with the field empty, takes off the last value |
Tab | Closes the list and moves on |
The highlight stops at the ends rather than wrapping, and starts on the chosen value when it's listed. Leaving the field closes the list and puts back what's chosen, whatever was typed.
Semantics
The field is a combobox, expanded or collapsed with the list, and the list is a listbox of options, each numbered with posInSet and setSize and marked selected when chosen. In multiple, the chosen values are a list inside the field, before its text, each with a button named Remove <label>.