Skip to content

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 ​

PropTypeDefaultWhat it does
optionsreadonly ComboboxOption[]requiredThe options. Declare the array once.
valuestringnoneThe chosen value, '' for none, when the application owns it.
defaultValuestringnoneThe value to start on, when the combobox owns it.
onChange(value: string) => voidnoneCalled with the value chosen.
multiplebooleanfalseSeveral values: each choice toggles one, and the list stays open.
valuesreadonly string[]noneThe chosen values in multiple, when the application owns them.
defaultValuesreadonly string[]noneThe values to start on in multiple, when the combobox owns them.
onValuesChange(values: readonly string[]) => voidnoneCalled with the values after a choice or a removal.
labelstring''Drawn above the field, and the name of the field and the list.
labelHiddenbooleanfalseThe label still names the field but isn't drawn.
placeholderstring''Drawn in the empty field.
descriptionstring''Shown under the field and read after its name.
errorstring''What's wrong, shown in place of the description; implies invalid.
invalidbooleanfalseDraws the border in danger and adds the invalid state.
requiredbooleanfalseAdds the required state. It enforces nothing.
disabledbooleanfalseRefuses typing and presses, and greys the field.
emptyTextstring'No matches'Shown in the list when nothing matches.
onQueryChange(query: string) => voidnoneCalled with the text as it's typed, for a list searched somewhere else.
filterbooleantrueFilters options by what's typed. false shows them as given, already narrowed by a search.
listHeightnumber280The list's tallest before it scrolls. The highlight is kept in view as it moves.
refUiNodeRefnoneReceives 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.

KeyWhat it does
TypingFilters the list, opening it
DownOpens the list, or moves to the next option that can be chosen
UpOpens the list, or moves to the previous one
EnterChooses the highlighted option. Single closes; multiple toggles and stays
EscapeCloses the list. With it closed, puts back the chosen label
BackspaceIn multiple, with the field empty, takes off the last value
TabCloses 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>.