Monet
@etched/monet 0.0.1

Combobox

Ported

Pick from a long list with search. Covers single and multiple choice, remote search and creating new items. Native <button> attributes and ref pass through to the trigger (with inline, there is no trigger).

Kumo source
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";

const owners: ComboboxItemData[] = [
  { value: "", label: "Unassigned", pinned: true },
  { value: "ana", label: "Ana Ruiz", description: "ana@etched.com" },
  { value: "jose", label: "José Núñez", description: "jose@etched.com" },
  { value: "sam", label: "Sam Lee", description: "sam@etched.com", searchText: "samuel" },
  { value: "kai", label: "Kai Ito", description: "kai@etched.com" },
  { value: "lee", label: "Lee Park (left)", hidden: true },
];

export function Preview() {
  return <Combobox aria-label="Owner" items={owners} placeholder="Choose an owner" />;
}

Installation

import { Combobox, ComboboxChip, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxGroupLabel, ComboboxInput, ComboboxItem, ComboboxList, ComboboxRoot, ComboboxTriggerInput, ComboboxTriggerMultipleWithInput, ComboboxTriggerValue } from "@etched/monet/combobox";

Examples

Single choice

import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";

const owners: ComboboxItemData[] = [
  { value: "", label: "Unassigned", pinned: true },
  { value: "ana", label: "Ana Ruiz", description: "ana@etched.com" },
  { value: "jose", label: "José Núñez", description: "jose@etched.com" },
  { value: "sam", label: "Sam Lee", description: "sam@etched.com", searchText: "samuel" },
  { value: "kai", label: "Kai Ito", description: "kai@etched.com" },
  { value: "lee", label: "Lee Park (left)", hidden: true },
];

const statuses: ComboboxItemData[] = [
  { value: "new", label: "New", tone: "neutral" },
  { value: "screen", label: "Phone screen", tone: "blue" },
  { value: "onsite", label: "Onsite", tone: "purple" },
  { value: "offer", label: "Offer", tone: "green" },
  { value: "rejected", label: "Rejected", tone: "red" },
  { value: "trial", label: "Work trial (custom)", hue: 236 },
];

const tone = new Map([
  ["offer", "var(--et-success)"],
  ["rejected", "var(--et-danger)"],
]);

const pill = (item: ComboboxItemData) => <span style={{ color: tone.get(item.value) ?? "var(--et-text)" }}>● {item.label}</span>;

// Saves on choice and is disabled while it saves, as Orbit's table cells are; focus comes back after.
function SavesOnChoice() {
  const [value, setValue] = useState<string | null>("ana");
  const [saving, setSaving] = useState(false);

  return (
    <Combobox
      aria-label="Owner, saves on choice"
      items={owners}
      value={value}
      disabled={saving}
      onValueChange={(next) => {
        setValue(next);
        setSaving(true);
        setTimeout(() => setSaving(false), 300);
      }}
    />
  );
}

export function SingleChoice() {
  return (
    <div className="row">
      <Combobox aria-label="Owner" items={owners} placeholder="Choose an owner" searchLabel="Search owners" />
      <Combobox aria-label="Former owner" items={owners} defaultValue="lee" />
      <Combobox aria-label="Status" items={statuses} defaultValue="offer" renderItem={pill} renderValue={pill} size="sm" />
      <Combobox aria-label="Stage" appearance="pill" items={statuses} defaultValue="screen" />
      <Combobox aria-label="Owner, disabled" items={owners} defaultValue="ana" disabled />
      <SavesOnChoice />
      <Combobox aria-label="Owner, read-only" items={owners} defaultValue="sam" readOnly />
    </div>
  );
}

Long lists

virtualized renders only the rows in view, so 5,000 candidates stay fast. Every row has a description, so all rows are one height. Values can be any type; these are number ids.

Candidate

Candidate id 4321.

import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";

const firstNames = "Ana Ben Chen Dana Eli Fatima Gus Hana Ivan Jia Kai Lena Mateo Nina Omar Priya Quinn Rosa Sam Tara Uma Vikram Wen Xin Yara Zoe Aiko Bruno Carmen Dev Emma Felix Grace Hugo Isla Jon Kira Leo Maya Noah Olga Pablo Rhea Sofia Theo Una Victor Will Yusuf Zara".split(" ");

const lastNames = (
  "Abe Adler Baker Banda Costa Cruz Dahl Diaz Eze Evans Fox Funk Garcia Gill Hale Han Ito Iyer Jain Jung Kato Khan Lam Lopez Mehta Moss Nagy Ng Ochoa Ono Park Patel Qureshi Quon Reyes Roy " +
  "Sato Shah Tan Tran Ueda Ulloa Vance Vega Wang Watts Xu Xiong Yang Yoon Zhou Zubair Arora Bell Choi Doyle Endo Ford Gupta Hart Ibarra Jensen Kaur Lin Mora Nair Ortiz Pham Rao Silva " +
  "Tanaka Usman Volkov Wu Young Zhang Bose Cho Das Frey Goh Hsu Iqbal Joshi Kim Liu Meyer Nolan Obi Pena Rossi Singh Teo Ueno Vu Weber Yu Ziegler Ahmed Moreno"
).split(" ");

// 5,000 candidates with number ids: every first and last name pair.
const candidates: ComboboxItemData<number>[] = lastNames.flatMap((last, l) =>
  firstNames.map((first, f) => ({ value: l * firstNames.length + f + 1, label: `${first} ${last}`, description: `${first}.${last}@example.com`.toLowerCase() })),
);

export function LongLists() {
  const [candidate, setCandidate] = useState<number | null>(4321);

  return (
    <div className="grid">
      <Combobox
        label="Candidate"
        virtualized
        items={candidates}
        value={candidate}
        onValueChange={setCandidate}
        placeholder="Choose a candidate"
        description={candidate == null ? "No one chosen." : `Candidate id ${candidate}.`}
      />
    </div>
  );
}

In a form

Owner

Search by name or email. Accents are optional.

Label
Reviewer
Choose a reviewer before you submit.

Approvers see every note.

import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";

const owners: ComboboxItemData[] = [
  { value: "", label: "Unassigned", pinned: true },
  { value: "ana", label: "Ana Ruiz", description: "ana@etched.com" },
  { value: "jose", label: "José Núñez", description: "jose@etched.com" },
  { value: "sam", label: "Sam Lee", description: "sam@etched.com", searchText: "samuel" },
  { value: "kai", label: "Kai Ito", description: "kai@etched.com" },
  { value: "lee", label: "Lee Park (left)", hidden: true },
];

function Labels() {
  const [items, setItems] = useState<ComboboxItemData[]>([{ value: "urgent", label: "Urgent" }, { value: "referral", label: "Referral" }]);
  const [value, setValue] = useState<string | null>(null);

  return (
    <Combobox
      label="Label"
      items={items}
      value={value}
      onValueChange={setValue}
      placeholder="Choose or create a label"
      onCreate={(label) => {
        const item = { value: label.toLowerCase(), label };
        setItems((all) => [...all, item]);
        setValue(item.value);
      }}
    />
  );
}

/** Labelled and described by elements around it, and told when it opens or closes. */
function LabelledElsewhere() {
  const [open, setOpen] = useState(false);

  return (
    <div className="grid">
      <label htmlFor="combobox-elsewhere">Approver</label>
      <Combobox id="combobox-elsewhere" aria-describedby="combobox-elsewhere-hint" items={owners} placeholder="Choose an approver" onOpenChange={setOpen} />
      <p id="combobox-elsewhere-hint">{open ? "The list is open." : "Approvers see every note."}</p>
    </div>
  );
}

export function InAForm() {
  return (
    <div className="grid">
      <Combobox label="Owner" items={owners} placeholder="Choose an owner" description="Search by name or email. Accents are optional." />
      <Labels />
      <Combobox label="Reviewer" items={owners} placeholder="Choose a reviewer" error="Choose a reviewer before you submit." />
      <LabelledElsewhere />
    </div>
  );
}

Composable parts (Kumo)

import { ComboboxChip, ComboboxCollection, ComboboxContent, ComboboxEmpty, ComboboxGroup, ComboboxGroupLabel, ComboboxInput, ComboboxItem, ComboboxList, ComboboxRoot, ComboboxTriggerInput, ComboboxTriggerMultipleWithInput, ComboboxTriggerValue } from "@etched/monet/combobox";

const benches = ["B-12", "B-14", "B-15", "P-02", "P-07", "T-01"];

const sites = [
  { value: "Taiwan", items: ["TPE-1", "TPE-2"] },
  { value: "United States", items: ["SJC-1", "AUS-3"] },
];

export function ComposablePartsKumo() {
  return (
    <div className="grid">
      <ComboboxRoot label="Bench" items={benches}>
        <ComboboxTriggerInput placeholder="Type a bench" />
        <ComboboxContent>
          <ComboboxEmpty>No benches match.</ComboboxEmpty>
          <ComboboxList>
            {(bench: string) => (
              <ComboboxItem key={bench} value={bench}>
                {bench}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </ComboboxRoot>
      <ComboboxRoot label="Benches" items={benches} multiple defaultValue={["B-14"]}>
        <ComboboxTriggerMultipleWithInput<string>
          placeholder="Add a bench"
          renderItem={(bench) => <ComboboxChip key={bench}>{bench}</ComboboxChip>}
        />
        <ComboboxContent>
          <ComboboxEmpty />
          <ComboboxList>
            {(bench: string) => (
              <ComboboxItem key={bench} value={bench}>
                {bench}
              </ComboboxItem>
            )}
          </ComboboxList>
        </ComboboxContent>
      </ComboboxRoot>
      <ComboboxRoot label="Site" items={sites}>
        <ComboboxTriggerValue placeholder="Choose a site" />
        <ComboboxContent>
          <ComboboxInput placeholder="Search sites" />
          <ComboboxEmpty />
          <ComboboxList>
            {(group: (typeof sites)[number]) => (
              <ComboboxGroup key={group.value} items={group.items}>
                <ComboboxGroupLabel>{group.value}</ComboboxGroupLabel>
                <ComboboxCollection>
                  {(site: string) => (
                    <ComboboxItem key={site} value={site}>
                      {site}
                    </ComboboxItem>
                  )}
                </ComboboxCollection>
              </ComboboxGroup>
            )}
          </ComboboxList>
        </ComboboxContent>
      </ComboboxRoot>
    </div>
  );
}

Inline

Unassigned
Ana Ruizana@etched.com
José Núñezjose@etched.com
Sam Leesam@etched.com
Kai Itokai@etched.com
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";

const owners: ComboboxItemData[] = [
  { value: "", label: "Unassigned", pinned: true },
  { value: "ana", label: "Ana Ruiz", description: "ana@etched.com" },
  { value: "jose", label: "José Núñez", description: "jose@etched.com" },
  { value: "sam", label: "Sam Lee", description: "sam@etched.com", searchText: "samuel" },
  { value: "kai", label: "Kai Ito", description: "kai@etched.com" },
  { value: "lee", label: "Lee Park (left)", hidden: true },
];

export function Inline() {
  return (
    <div className="grid">
      <Combobox aria-label="Add a person" inline items={owners} searchPlaceholder="Search people" />
    </div>
  );
}

Palette

inline with appearance="palette": a command palette for a dialog, as in Orbit's candidate search. Nothing shows under the search until you type; onClose adds the esc key, and Escape in the search calls it too.

Closed 0 times

import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";

const owners: ComboboxItemData[] = [
  { value: "", label: "Unassigned", pinned: true },
  { value: "ana", label: "Ana Ruiz", description: "ana@etched.com" },
  { value: "jose", label: "José Núñez", description: "jose@etched.com" },
  { value: "sam", label: "Sam Lee", description: "sam@etched.com", searchText: "samuel" },
  { value: "kai", label: "Kai Ito", description: "kai@etched.com" },
  { value: "lee", label: "Lee Park (left)", hidden: true },
];

export function Palette() {
  const [found, setFound] = useState<ComboboxItemData[]>([]);
  const [closed, setClosed] = useState(0);

  return (
    <div className="card">
      <Combobox
        aria-label="Find a person"
        inline
        appearance="palette"
        items={found}
        searchPlaceholder="Search people or teams"
        onSearch={(query) => setFound(query ? owners.filter((o) => !o.pinned && !o.hidden && o.label.toLowerCase().includes(query.toLowerCase())) : [])}
        onClose={() => setClosed((count) => count + 1)}
      />
      <p className="caption">Closed {closed} {closed === 1 ? "time" : "times"}</p>
    </div>
  );
}

Filter

appearance="filter" is an icon-only funnel for a column heading. It fills while a filter is on; screen readers hear the choice as its description.

import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";

const owners: ComboboxItemData[] = [
  { value: "", label: "Unassigned", pinned: true },
  { value: "ana", label: "Ana Ruiz", description: "ana@etched.com" },
  { value: "jose", label: "José Núñez", description: "jose@etched.com" },
  { value: "sam", label: "Sam Lee", description: "sam@etched.com", searchText: "samuel" },
  { value: "kai", label: "Kai Ito", description: "kai@etched.com" },
  { value: "lee", label: "Lee Park (left)", hidden: true },
];

const statuses: ComboboxItemData[] = [
  { value: "new", label: "New", tone: "neutral" },
  { value: "screen", label: "Phone screen", tone: "blue" },
  { value: "onsite", label: "Onsite", tone: "purple" },
  { value: "offer", label: "Offer", tone: "green" },
  { value: "rejected", label: "Rejected", tone: "red" },
  { value: "trial", label: "Work trial (custom)", hue: 236 },
];

const tone = new Map([
  ["offer", "var(--et-success)"],
  ["rejected", "var(--et-danger)"],
]);

export function Filter() {
  const [shown, setShown] = useState<string[]>(["screen", "offer"]);

  return (
    <div className="row">
      <Combobox multiple appearance="filter" aria-label="Filter by status" items={statuses} value={shown} onValueChange={setShown} />
      <Combobox multiple appearance="filter" aria-label="Filter by owner" items={owners} defaultValue={[]} />
    </div>
  );
}

Score box

appearance="box" with items that have a level: the trigger is a small box in the score's fill, and each option fills its row. A legacy score with no level has no fill.

import { useState } from "react";
import { Combobox, type ComboboxItemData } from "@etched/monet/combobox";

const conviction: ComboboxItemData[] = [
  { value: "1", label: "1", level: 1, description: "Profile looks good, unsure" },
  { value: "2", label: "2", level: 2, description: "Profile looks extra good, unsure" },
  { value: "3", label: "3", level: 3, description: "Additional backchannel or tribal knowledge about this person" },
  { value: "4", label: "4", level: 4, description: "Certified superstar has been deeply referenced" },
  { value: "10", label: "10", hidden: true },
];

export function ScoreBox() {
  const [score, setScore] = useState<string | null>("3");

  return (
    <div className="row">
      <Combobox aria-label="Conviction" appearance="box" size="sm" items={conviction} value={score} onValueChange={setScore} />
      <Combobox aria-label="Legacy conviction" appearance="box" size="sm" items={conviction} defaultValue="10" />
    </div>
  );
}

Accessibility

  • On the trigger, Enter or ArrowDown opens the list with focus in its search, and a typed letter starts the search. ArrowUp/ArrowDown move the highlight; Enter picks.
  • Escape closes only the list, also in a dialog. Tab/Shift+Tab close it and move on. After a pick, focus goes back to the trigger.
  • The trigger is a combobox that opens a dialog, named by label or aria-label, as is the list. The search field is a combobox named by searchLabel.
  • The trigger has aria-expanded; options have aria-selected. status and the empty text are polite status regions. A filter trigger is described by its chosen values.
  • Without label, give it an aria-label; the filter appearance always needs one. Set searchLabel when the search placeholder is not a good name.
  • With the input parts, Escape on a closed list clears the value. Chips are a toolbar; Backspace in an empty field removes the last chip.

API reference

Combobox

PropTypeDefaultDescription
labelReactNode–Visible label. Without one, give the control an aria-label.
labelTooltipReactNode–More about the field, behind an info button after the label.
labelTooltipLabelstring"More information"Names the info button.
descriptionReactNode–Help text below the control. Hidden while an error shows.
errorFieldError–
items *ComboboxItemData<T>[]–
multipleMultiple–
valueValue<T, Multiple>–
defaultValueValue<T, Multiple>–
onValueChange(value: Value<T, Multiple>) => void–
itemToStringValue(value: T) => stringStringA value as a string: its form value, its data-value test hook and its key. Give it when values are objects.
requiredboolean–
disabledboolean–
readOnlyboolean–Keeps the value. The list still opens to show the options.
namestring–Form field name.
size"sm" | "base""base"Same heights as Input.
appearance"default" | "pill" | "ghost" | "filter" | "box" | "palette""default"pill is a rounded, borderless trigger for statuses in tables (Orbit\'s status dropdown). ghost has no edge or fill until hover, for values in table cells; it takes the selected option\'s ink.
placeholderReactNode–Trigger text while nothing is selected.
searchPlaceholderstring"Search"
searchLabelstring`searchPlaceholder`Names the search field, when its placeholder is not a good name.
renderItem(item: ComboboxItemData<T>) => ReactNode–Custom option content, for example a status pill.
renderValue(item: ComboboxItemData<T>) => ReactNode–Custom trigger content for the selected item (single selection).
onSearch(query: string) => void–Remote search: called on every query. Local filtering turns off.
statusReactNode–Live status under the list, for example "Searching…".
emptyTextReactNode"No matches."
onCreate(label: string) => void–Offers the typed text as a new item when no label matches it (single selection).
createLabel(text: string) => string`Create “${text}”`The row that offers the typed text.
createHintLabelstring"Press Enter or Tab to create"The hint under that row.
existsLabelstring"Already exists"The hint under an item whose label is the typed text.
footerReactNode | false–Bottom of the list. Multiple selection shows "N selected" and Done unless this is false or set.
selectedLabel(count: number) => string`${count} selected`The count of chosen items, on the trigger and in the footer of a multiple selection.
doneLabelstring"Done"The footer's button that closes the list.
inlineboolean–Search field and list in place, with no trigger or popup.
virtualizedboolean–Renders only the rows in view, so a list of thousands stays fast. Every row must have the same height: give every item a description, or none.
openboolean–Whether the list is open, when the app controls it.
defaultOpenbooleanfalseWhether the list is open at first.
onOpenChange(open: boolean) => void–Called when the list opens or closes.
onClose() => void–With inline: an esc key at the end of the search row, and Escape in the search, call it (a palette in a dialog).
closeLabelstring"Close"Names the esc key that onClose adds.
classNamestring–Class on the trigger (or the inline panel), for layout such as width.
sideBaseCombobox.Positioner.Props["side"]"bottom"
alignBaseCombobox.Positioner.Props["align"]"start"
containerBaseCombobox.Portal.Props["container"]document.bodyThe element the list's portal goes into, for example a shadow root.

ComboboxChip

PropTypeDefaultDescription
classNamestring–
removeLabelstring"Remove"Names the remove button.

ComboboxContent

PropTypeDefaultDescription
classNamestring–
children *ReactNode–
containerBaseCombobox.Portal.Props["container"]document.bodyThe element the portal goes into, for example a shadow root.

ComboboxEmpty

PropTypeDefaultDescription
classNamestring–

ComboboxGroupLabel

PropTypeDefaultDescription
classNamestring–

ComboboxGroup

PropTypeDefaultDescription
classNamestring–

ComboboxInput

PropTypeDefaultDescription
classNamestring–
endReactNode–After the field in the same row, such as a close key.

ComboboxItem

PropTypeDefaultDescription
classNamestring–
level1 | 2 | 3 | 4–A step on the green ramp, for an ordinal score such as conviction 1–4. Wins over hue and tone. A Pill with a level is a small score box, and a list option with one takes the fill across its row.

ComboboxList

PropTypeDefaultDescription
classNamestring–

ComboboxRoot

PropTypeDefaultDescription
labelReactNode–Visible label. Without one, give the control an aria-label.
labelTooltipReactNode–More about the field, behind an info button after the label.
labelTooltipLabelstring"More information"Names the info button.
descriptionReactNode–Help text below the control. Hidden while an error shows.
errorFieldError–
size"sm" | "base""base"Trigger height, as Input.

ComboboxTriggerInput

PropTypeDefaultDescription
classNamestring–
clearLabelstring"Clear selection"Names the clear button.
showOptionsLabelstring"Show options"Names the button that opens the list.

ComboboxTriggerMultipleWithInput

PropTypeDefaultDescription
classNamestring–
renderItem *(value: Value) => ReactNode–Renders one selected value, usually as a ComboboxChip.
inputSide"right" | "top""right"

ComboboxTriggerValue

PropTypeDefaultDescription
classNamestring–