Reticle@etched/reticle 0.0.0

Combobox

Pick from a long list with search. Covers single and multiple choice, remote search and creating new items.

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

const owners: ComboboxItemData[] = [
  { value: "none", 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 } from "@etched/reticle/combobox";

Examples

Single choice

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

const owners: ComboboxItemData[] = [
  { value: "none", 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 />
    </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/reticle/combobox";

const owners: ComboboxItemData[] = [
  { value: "none", 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/reticle/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/reticle/combobox";

const owners: ComboboxItemData[] = [
  { value: "none", 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/reticle/combobox";

const owners: ComboboxItemData[] = [
  { value: "none", 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/reticle/combobox";

const owners: ComboboxItemData[] = [
  { value: "none", 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/reticle/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>
  );
}

API reference

Combobox

PropTypeDefaultDescription
items *ComboboxItemData[]–
multipleMultiple–
valueValue<Multiple>–
defaultValueValue<Multiple>–
onValueChange(value: Value<Multiple>) => void–
labelReactNode–Visible label. Without one, give the combobox an aria-label.
aria-labelstring–
descriptionReactNode–
errorFieldError–
requiredboolean–
disabledboolean–
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) => ReactNode–Custom option content, for example a status pill.
renderValue(item: ComboboxItemData) => 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).
footerReactNode | false–Bottom of the list. Multiple selection shows "N selected" and Done unless this is false or set.
inlineboolean–Search field and list in place, with no trigger or popup.
idstring–The trigger's id, for a label elsewhere (<label htmlFor>).
aria-describedbystring–Help text elsewhere that describes the trigger.
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).
classNamestring–Class on the trigger (or the inline panel), for layout such as width.
sideBaseCombobox.Positioner.Props["side"]"bottom"
alignBaseCombobox.Positioner.Props["align"]"start"

ComboboxContent

PropTypeDefaultDescription
children *ReactNode–
classNamestring–

ComboboxRoot

PropTypeDefaultDescription
labelReactNode–Visible label, with help text and error below, as in Field.
descriptionReactNode–
errorFieldError–
size"sm" | "base""base"Trigger height, as Input.