One choice from a long list, found by typing (autocomplete, typeahead, searchable select): a driver, a service, an account. The list filters along while the value stays exactly one.
import { Combobox } from "@umriss-ui/core";
What is typed is a search term, not a value: the value changes only when a row is chosen or the field is cleared, and it is one option or null.
The list keeps its given order while it filters; sort the options before you pass them if you want an order.
Typing filters the options; onChange runs only when a row is chosen or cleared, with one value or null.
import { useState } from "react";
import { Combobox, FormField, Stack, Text } from "@umriss-ui/core";
const DRIVERS = [
{ value: "d1", label: "Martin Hale" },
{ value: "d2", label: "Nadia Petrova" },
{ value: "d3", label: "Owen Carter" },
{ value: "d4", label: "Lucia Romero" },
{ value: "d5", label: "Ben Adeyemi" },
{ value: "d6", label: "Hanna Berg" },
{ value: "d7", label: "Yusuf Demir" },
{ value: "d8", label: "Clara Wendt" },
];
export default function PickADriver() {
const [driver, setDriver] = useState<string | null>(null);
return (
<Stack gap={3} style={{ maxWidth: 320 }}>
<FormField label="Driver, tour T-04">
<Combobox value={driver} onChange={setDriver} clearable options={DRIVERS} placeholder="Type a name" />
</FormField>
<Text size="xs" tone="muted" mono>
value: {driver === null ? "null" : `"${driver}"`}
</Text>
</Stack>
);
}
Set emptyText for a search that finds nothing; errors come from the FormField, and a disabled row stays in the list.
import { Combobox, FormField, Grid, type ComboboxOption } from "@umriss-ui/core";
const VEHICLES: ComboboxOption[] = [
{ value: "v1", label: "FP 214 K, van, North depot" },
{ value: "v2", label: "FP 377 K, e-van, North depot" },
{ value: "v3", label: "FP 118 R, truck, North depot, in the workshop until Thursday", disabled: true },
{ value: "v4", label: "FP 402 R, van, Riverside depot" },
];
export default function States() {
return (
<Grid minItemWidth="240px" gap={4}>
<FormField label="Vehicle" hint="Type “zz” to see the empty result.">
<Combobox value={null} onChange={() => {}} options={VEHICLES} emptyText="No vehicle matches. Check the plate." />
</FormField>
<FormField label="Vehicle" error="Tour T-04 needs a vehicle before it can be released.">
<Combobox value={null} onChange={() => {}} options={VEHICLES} placeholder="Choose a vehicle" />
</FormField>
<FormField label="Vehicle" hint="The tour is on the road.">
<Combobox value="v2" onChange={() => {}} options={VEHICLES} disabled />
</FormField>
<FormField label="Vehicle" hint="A long label is cut at the field's edge.">
<Combobox value="v3" onChange={() => {}} options={VEHICLES} />
</FormField>
</Grid>
);
}
A FormField with an error marks the field invalid by itself. Without one, set invalid and tie your message to the field with aria-describedby.
import { useId, useState } from "react";
import { Combobox, Stack, Text } from "@umriss-ui/core";
const VEHICLES = [
{ value: "v1", label: "FP 214 K, van, North depot" },
{ value: "v2", label: "FP 377 K, e-van, North depot" },
{ value: "v4", label: "FP 402 R, van, Riverside depot" },
];
export default function WithAnError() {
const [vehicle, setVehicle] = useState<string | null>(null);
const messageId = useId();
const error = vehicle === null ? "Tour T-04 needs a vehicle before it can be released." : undefined;
return (
<Stack gap={1} style={{ maxWidth: 360 }}>
<Combobox
aria-label="Vehicle"
value={vehicle}
onChange={setVehicle}
options={VEHICLES}
placeholder="Choose a vehicle"
invalid={error !== undefined}
aria-describedby={error ? messageId : undefined}
/>
{error && (
<Text id={messageId} size="xs" style={{ color: "var(--u-color-danger-text)" }}>
{error}
</Text>
)}
</Stack>
);
}
Build options from your records, with the team in the label so typing finds either; one choice decides the next.
import { useState } from "react";
import { Button, Card, CardBody, CardHeader, Combobox, FormField, Stack } from "@umriss-ui/core";
/* Data from the operations world, written out here so the example runs on its own. */
interface Service {
id: string;
name: string;
team: string;
/** 1 is customer-facing and pages at night; 3 waits for the morning. */
tier: 1 | 2 | 3;
/** The latency objective: the 95th percentile stays below this, in ms. */
latencySlo: number;
/** The availability promised for a month, in per cent. */
availabilityTarget: number;
}
const SERVICES: readonly Service[] = [
{ id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
{ id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
{ id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
{ id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
{ id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
{ id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
{ id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
{ id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];
interface Engineer {
id: string;
name: string;
team: string;
}
const ENGINEERS: readonly Engineer[] = [
{ id: "priya", name: "Priya Raman", team: "Payments" },
{ id: "jonas", name: "Jonas Keller", team: "Payments" },
{ id: "ada", name: "Ada Mwangi", team: "Identity" },
{ id: "tomasz", name: "Tomasz Nowak", team: "Discovery" },
{ id: "leila", name: "Leila Haddad", team: "Discovery" },
{ id: "sam", name: "Sam Okafor", team: "Messaging" },
{ id: "ines", name: "Ines Duarte", team: "Integrations" },
{ id: "felix", name: "Felix Brandt", team: "Insights" },
];
export default function AssignAnIncident() {
const [service, setService] = useState<string | null>("checkout");
const [owner, setOwner] = useState<string | null>(null);
const team = SERVICES.find((s) => s.id === service)?.team;
return (
<Card style={{ maxWidth: 480 }}>
<CardHeader eyebrow="INC-1049 · SEV2" title="Assign the incident" />
<CardBody>
<Stack gap={4}>
<FormField label="Service">
<Combobox
value={service}
onChange={(next) => {
setService(next);
setOwner(null);
}}
options={SERVICES.map((s) => ({ value: s.id, label: `${s.name} · ${s.team}` }))}
/>
</FormField>
<FormField label="Incident lead" hint={team ? `Engineers of ${team} first.` : undefined}>
<Combobox
value={owner}
onChange={setOwner}
clearable
placeholder="Type a name or team"
options={[...ENGINEERS]
.sort((a, b) => Number(b.team === team) - Number(a.team === team))
.map((e) => ({ value: e.id, label: `${e.name} · ${e.team}` }))}
/>
</FormField>
<Button variant="primary" size="sm" disabled={owner === null} style={{ alignSelf: "flex-start" }}>
Assign
</Button>
</Stack>
</CardBody>
</Card>
);
}
| Key | Action |
|---|---|
Arrow Down / Arrow Up |
Opens the list, then moves through the rows. |
Enter |
Chooses the active row. |
Escape |
Closes the list. |
ComboboxProps<T extends string = string>| Name | Type | Default | Description |
|---|---|---|---|
optionsrequired | readonly ComboboxOption<T>[] | — | The possibilities, in their natural order. Filtered while typing; never
reordered. Shown in: Configurator, Combobox, States and 5 more |
valuerequired | T | null | — | The chosen value; null means none. Always controlled - the field keeps
no second state beside the caller's.Shown in: Configurator, Combobox, States and 5 more |
placeholder | string | — | What stands in the empty field. Shown in: Combobox, States, With an error and 3 more |
size | "sm" | "md" | the size of a ControlSizeProvider around it, else "md" | sm for a toolbar and dense forms, md otherwise.Shown in: Configurator |
chars | number | — | The width in characters - room for the typed text and the chosen
option; the field adds its own padding, chevron and cross. Given, the
field is that wide wherever it stands, and never wider than its place.
Without it the field fills its place, and is 20 characters wide where the
place asks - in a toolbar or a row. The panel is never narrower than the
field. Shown in: Configurator |
disabled | boolean | false | Locks field and panel. Shown in: Configurator, States, Dispatch a tour (Scenarios) |
invalid | boolean | — | Marks the field as invalid. FormField sets it itself as soon as it
carries an error - by hand only necessary without FormField.Shown in: Configurator, With an error |
emptyText | string | — | Text shown for an empty search result. Shown in: States |
clearable | boolean | false | Shows a cross on selection or input that clears field and selection. Shown in: Configurator, Combobox, Assign an incident and 2 more |
ref | Ref<HTMLDivElement> | — | Receives the component's root element. from RefPropShown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more |
| Name | Type | Default | Description |
|---|---|---|---|
onChangerequired | (value: T | null) => void | — | Runs on choosing and on clearing (null). Not while typing: what is
typed is a search term and not yet a value.Shown in: Configurator, Combobox, States and 5 more |
Also takes every attribute of <div> – without defaultValue, onChange.
ComboboxOption<T extends string = string>| Name | Type | Default | Description |
|---|---|---|---|
valuerequired | T | — | What comes back when this row is chosen. Shown in: States, Assign an incident, Dispatch a tour (Scenarios) and 1 more |
labelrequired | string | — | What stands there - and what is filtered on. Text and not a node: what
cannot be searched is something nobody finds in a combobox. Shown in: States, Assign an incident, Dispatch a tour (Scenarios) and 1 more |
disabled | boolean | — | Shows the row but does not let it be chosen. Shown in: States |
What umriss deliberately does not build, and why: ADR-0032.