> ## Documentation Index
> Fetch the complete documentation index at: https://help.stralos.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Combobox

> Shared text-input-with-options control in @stralos-co-nz/ui

`@stralos-co-nz/ui` exports `Combobox`, `ComboboxOption`, and `ComboboxProps`. It is the suite's standard control for picking from a set, whether that set is a searchable library, a grouped catalogue, or a short fixed list.

```tsx theme={null}
import { Combobox, type ComboboxOption } from "@stralos-co-nz/ui";
```

## Design rule

Typing is sacred. The component never writes to the field, never moves the caret, and never spends a key it does not own. Every change to the displayed text is a write by the caller, made in response to `onValueChange` or `onSelect`. This replaces the browser's native `<datalist>`, which re-completed the entry during a pause mid-deletion and left the caret at the end of a word that the user had not typed, making Backspace unwinnable.

## Clipped values and hover titles

When a committed value is too long to fit the field width, the field clips the text with an ellipsis (`…`). The full text is exposed via the element's `title` attribute so users can hover to read it.

Title precedence on the input:

1. A caller-supplied `title` prop (takes priority always).
2. `value · trailingText` when a trailing figure is present (the same rule as before this change).
3. `valueTitle` when non-empty (use this for the committed label).
4. No `title` attribute otherwise.

Pass `valueTitle` only while the field is showing the committed value, not while the user is typing. When the user opens the list and starts a new query, `valueTitle` should be `undefined` — the component cannot make that determination from `value` alone.

`SelectCombobox` manages this automatically: it passes the committed choice's label (or the unknown-value text) as `valueTitle` when the query state is `null`, meaning no search is in progress. The title disappears the moment the user begins typing.

## Option type

```ts theme={null}
type ComboboxOption = {
  value: string;       // what a chosen option puts in the field; used as identity when id is absent
  label: string;       // prominent first line
  sublabel?: string;   // muted second line — code, category, unit, or any qualifier
  trailing?: string;   // right-aligned figure that must never be clipped, e.g. a price
  group?: string;      // group heading; options without one are listed first, ungrouped
  id?: string;         // explicit identity when value is not unique on its own
};
```

Callers may extend `ComboboxOption` with their own fields. The component is generic over the option type and hands the whole object back to `onSelect`, so a caller that must write several sibling fields from one choice never needs to look the option up again.

## Props

| Prop | Type | Default | Description |
| - | - | - | - |
| `value` | `string` | Required | The field's current text. Fully controlled — the component never owns display state. |
| `options` | `O[]` | Required | The rows to offer. Filtered as the user types. |
| `trailingText` | `string` | — | A non-interactive figure shown at the field's end, before the indicators (chevron, clear button). Used by `SelectCombobox` to display the chosen option's `trailing` value while the field is closed. The input's inline-end padding grows to accommodate it, so a long label ellipsises up to the trailing text rather than underneath it. |
| `onValueChange` | `(value: string) => void` | Required | Reports a keystroke. The field is rewritten only by the caller applying this. |
| `valueTitle` | `string` | — | Full text shown as the field's hover title while displaying a settled value. The caller is responsible for passing this only when the field is not in a search-in-progress state — the component cannot derive that state from `value` alone. Omit it while the user is typing. Title precedence: a caller's `title` prop wins, then `value · trailingText` when a trailing figure is present, then `valueTitle`. |
| `onSelect` | `(option: O) => void` | — | Fires when the user deliberately chooses a row (Enter on the highlighted item, or a click). Receives the whole option object. |
| `onAbandon` | `() => void` | — | Fires when the user genuinely leaves the field — pointer or keyboard gesture confirmed. Use this instead of `onBlur` to discard a half-typed entry. See [Blur vs abandon](#blur-vs-abandon). |
| `onKeyDown` | `(event: KeyboardEvent) => void` | — | The caller's key handler, reached only for keys the list did not consume. |
| `selectedId` | `string` | — | The id (or value, when no explicit `id` is set) of the committed option. When supplied, the machine marks that row with `aria-selected` and a checked state, starts the keyboard highlight on it, and centres it in the panel when the list opens. Re-opening the list after loading more rows re-centres the chosen row unless the user has already typed or moved the highlight. Omit this on a free-text field where nothing is pre-selected. |
| `allowCustomValue` | `boolean` | `true` | Whether text that matches no option is allowed to stand. Set `false` for a strict select — see [Strict selection](#strict-selection). |
| `showDropArrow` | `boolean` | Derived from `allowCustomValue` | Shows a chevron at the trailing edge of the field. Clicking the arrow opens the list. Defaults to `true` when `allowCustomValue` is `false`, and `false` otherwise. Override only when the derived value is wrong for a specific use case. An arrow on a field that accepts free text is a bug. |
| `openOnClick` | `boolean` | Derived from `allowCustomValue` | Whether clicking the field opens the list. Defaults to `true` when `allowCustomValue` is `false` (strict fields), and `false` otherwise. A strict field is effectively a select — clicking it means "show me the options", so it opens immediately. A free-text field does not open on click because a click is how you reach the caret in what you've typed. Override only when the derived value is wrong for a specific use case. |
| `filter` | `(option: O, query: string) => boolean` | — | Custom match predicate. Defaults to a case- and accent-insensitive substring match over `label`, `sublabel`, `value`, and `trailing`. A supplied predicate replaces the default match. |
| `emptyMessage` | `ReactNode` | — | Shown in place of rows when a query matches nothing. Without it the list simply does not open, which is appropriate for a field that also accepts free text. |
| `busy` | `boolean` | `false` | Stops accepting input but keeps focus. Distinct from `disabled` because Chromium drops focus to `<body>` when a focused element is disabled, which breaks type-Enter-type entry loops. Sets `aria-busy="true"` and `readOnly` while active. |
| `onClear` | `() => void` | — | Supplying this places a × button beside the chevron. The component reports the intent; the caller performs the write. See [Externally-owned clear](#externally-owned-clear). |
| `clearLabel` | `string` | `"Clear"` | Accessible name for the clear button. Name it for what it clears when a page has several comboboxes. |
| `loadOptions` | `(query: string) => Promise<O[]>` | — | Fetches rows for a query. Makes the field a typeahead — see [Async / typeahead](#async-typeahead). |
| `onQueryChange` | `(query: string) => void` | — | The debounced query, for a caller that keeps its own rows. Fires on the same schedule as `loadOptions`. |
| `queryDebounceMs` | `number` | `300` | How long typing must pause before a query is issued. Address lookups typically want longer than 300 ms. |
| `initialQuery` | `string \| null` | `""` | The query issued on mount. `""` asks for the default page immediately. Pass `null` to skip the mount query for metered endpoints. Read once — use `reloadKey` to re-query later. |
| `reloadKey` | `string \| number` | — | Re-issues the current query when it changes. Use when the loader depends on something other than the typed query (a category, a project id) and rows would otherwise go stale until the next keystroke. |
| `loadingMessage` | `ReactNode` | — | Shown in the panel while the first rows are loading. Later loads keep existing rows visible. |
| `errorMessage` | `ReactNode` | — | Shown in the panel when a load rejects, above a retry button. Distinct from `emptyMessage` intentionally. |
| `retryLabel` | `string` | — | Label for the retry button shown after a load failure. |
| `inputRef` | `Ref<HTMLInputElement>` | — | Forwarded to the focusable input element. Compatible with `useFormValidation`. |
| `flex`, `minW`, `maxW`, `w` | Chakra tokens | — | Sizing props applied to the wrapper, not the inner input. |

Standard Chakra `Input` props (`placeholder`, `size`, `disabled`, `readOnly`, `aria-*`, etc.) are forwarded to the input element. Use `aria-invalid` for invalid state.

### Size values

The `size` prop accepts Chakra's standard values plus `"2xs"` for dense inline cells:

| Value | Height | Text | Notes |
| - | - | - | - |
| `"2xs"` | 28 px | 12 px | Dense inline-cell size — measured against 26 px pricing cells and 27.5 px toolbar buttons. |
| `"xs"` | 32 px | 12 px | |
| `"sm"` | 36 px | 14 px | |
| `"md"` | 40 px | 16 px | Default |
| `"lg"` | 48 px | 18 px | |

`"2xs"` was added because a size missing from the chevron-sizing map fell back to `md` silently, rendering a 16 px chevron behind 32 px of reserved width on a 28 px field.

## Chosen row on open

Pass `selectedId` to tell the list which row is already committed. When `selectedId` is set:

* The matching row is marked with `aria-selected` and a checked visual state (bold text, background highlight, and a tick).
* The keyboard highlight starts on that row when the list opens via a click, the chevron, or ArrowDown.
* The row is centred in the panel when the list opens, so the chosen row is visible immediately without the user needing to scroll. If rows are still loading when the list appears, the component re-centres to the chosen row once they land, unless the user has already typed or moved the highlight.
* When the user begins typing a new query, the highlight moves to the first matching row in the usual way.

Omit `selectedId` on free-text fields where no row is pre-committed.

`SelectCombobox` passes `selectedId` automatically from its committed `value`, so it opens on the chosen row with no extra prop. A bare `Combobox` opens on the chosen row only when the caller passes `selectedId`.

## Filtering

By default the list filters by a case- and accent-insensitive substring match across `label`, `sublabel`, `value`, and `trailing`. A sublabel containing a code such as `LABS` is therefore searchable even if the user does not know the name it belongs to. In the default match, `trailing` is searched as its own clause, so a price shown only there is findable by the number it displays. A custom `filter` replaces this default behavior.

Pass a custom `filter` function to change matching behaviour:

```tsx theme={null}
<Combobox
  filter={(option, query) =>
    option.label.toLowerCase().startsWith(query.toLowerCase())
  }
  ...
/>
```

When no options match and `emptyMessage` is not set, the list stays closed. When `emptyMessage` is set, the list opens and shows the message.

## List height

The dropdown list is capped at eight whole rows (288px for a standard `md` field, which uses 35px rows with 4px top and bottom padding on the content box). This means a long list always ends between rows rather than mid-word. The list scrolls when the option count exceeds eight rows.

## Keyboard behaviour

The list spends the keys it uses and passes the rest to the caller.

| Key | Effect |
| - | - |
| **ArrowDown** | Opens the list and moves the highlight down. |
| **ArrowUp** | Moves the highlight up. |
| **Enter** (highlighted row) | Selects the highlighted row. The Enter is not forwarded to the caller, so a grid or form that commits on Enter does not also save the partial text. |
| **Enter** (nothing highlighted) | Forwarded to the caller verbatim. A fast type-Enter-type loop keeps working because nothing is highlighted until it is asked for. |
| **Escape** | Unwinds one step at a time — list first, then the entry, then the caller — so nothing typed or open is ever discarded in a single keystroke. See [Escape behaviour](#escape-behaviour). |

## Escape behaviour

Escape unwinds one step at a time:

1. If the list is open, Escape closes it. The entry is unchanged. The Escape is not forwarded to the caller.
2. If the list is closed and the entry differs from what it was when the field was focused, Escape restores the original entry. The Escape is not forwarded to the caller. The snapshot is the value at focus time, not at mount time.
3. If neither condition applies, Escape is forwarded to the caller. A dialog's discard or an inline editor's cancel is therefore reachable, costing at most two extra keystrokes.

This prevents a dialog from being dismissed by the same Escape that was meant to close the list or restore a partial edit. The window-level capture handler is responsible for this guarantee — `stopPropagation` alone is insufficient because a dialog's dismiss listener runs in the capture phase before any handler on the input.

## Grouped options

Attach a `group` string to any option. Options without a group are listed first, ungrouped. Groups appear in first-appearance order.

```tsx theme={null}
const options = [
  { value: "Each", label: "Each", rateId: "u1" },
  { value: "Metre", label: "Metre", group: "Most Used", rateId: "u2" },
  { value: "Cubic metre", label: "Cubic metre", group: "Most Used", rateId: "u3" },
];
```

Group headings are rendered at `xs` weight in a muted colour. Each option's `sublabel` appears beneath its `label` in the list.

## Async typeahead

Supply `loadOptions` to make the field a typeahead. The component owns the debounce, the stale-response guard, the in-flight and failed states, and the loaded rows. The caller does not need to manage any of those.

`options` still works alongside `loadOptions` as a seed: seed rows are shown until the first response lands, so the field is not empty on mount. For a remote set with no meaningful seed, pass an empty array.

```tsx theme={null}
<Combobox
  value={query}
  options={[]}          // no seed; rows come entirely from the server
  loadOptions={async (q) => {
    const results = await searchRates(q);
    return results.map((r) => ({ value: r.id, label: r.name, sublabel: r.code }));
  }}
  onValueChange={setQuery}
  onSelect={(option) => setQuery(option.value)}
/>
```

While rows are loading, the chevron is replaced by a spinner and existing rows stay visible. When a load rejects, `errorMessage` appears in the panel with a retry button.

The component trims the query once before issuing it. Clearing the field issues an immediate query for `""` rather than waiting for the debounce.

## Strict selection

Set `allowCustomValue={false}` when the field is a way to search a list rather than a way to write a new value. On blur, any entry that does not exactly match an option is handed back to `onValueChange` as `""`, so the caller can clear or reject it.

```tsx theme={null}
<Combobox
  allowCustomValue={false}
  value={selectedUnit}
  options={unitOptions}
  onValueChange={setSelectedUnit}
  onSelect={(option) => setSelectedUnit(option.value)}
/>
```

An entry that exactly matches an option is left unchanged on blur.

A strict field opens on click. There is nothing to edit in a strict field — a click can only mean "show me the options". Both `openOnClick` and `showDropArrow` are derived from `allowCustomValue`, so there is no need to set them manually on a strict field.

When `loadOptions` is also supplied, the blur guard checks the server-loaded rows rather than the seed. A value committed from a server response is preserved on blur. Only text that neither the seed nor the server ever offered is cleared. This was a bug in 0.18.0 — fixed in 0.18.1.

## Controlled value and the caller-only write rule

The component is fully controlled. The caller applies `onValueChange` results to its own state, and the component displays whatever `value` it receives. A caller that needs to hold the previous value while a confirmation is in flight simply does not apply the new one — the displayed text and the value being filtered against stay in sync because the component reads `value` directly.

`onSelect` also does not write to the field. The caller is responsible for applying the selected option's `value` (or any other field) when appropriate.

## Blur vs abandon

Use `onAbandon` — not `onBlur` — to discard a half-typed entry or reset query state when the user leaves the field.

A blur event fires for every focus loss, including one triggered by the combobox machine itself. When two comboboxes share a cell (for example, a rate item and a material field in a calculator row), opening the second combobox causes the underlying library to call `focusInputEl` on the first input a frame later. That stolen blur arrives with `listOpen: true` and a real `relatedTarget` — indistinguishable from a genuine user departure. A handler on `onBlur` therefore discards the first character typed into the second field every time, which reads as "typing does nothing" rather than as a lost character.

`onAbandon` fires only when a user-initiated gesture (pointer or keyboard) caused the focus to leave this field. The distinction is provenance: a gesture sets a flag before the blur arrives; a machine's `requestAnimationFrame` call does not. There is no timer and no heuristic window.

```tsx theme={null}
// Correct — discard the typed query only when the user has left the field
<Combobox
  value={query}
  options={options}
  onValueChange={setQuery}
  onAbandon={() => setQuery("")}
/>

// Incorrect — discards the first character when a sibling combobox opens
<Combobox
  value={query}
  options={options}
  onValueChange={setQuery}
  onBlur={() => setQuery("")}  // do not do this
/>
```

`onBlur` remains available as the raw DOM handler for callers that need it (validation, analytics). Abandon behaviour must use `onAbandon`.

`SelectCombobox` hooks its own query-clearing to `onAbandon` internally. Callers of `SelectCombobox` do not need to set this themselves.

## Usage

```tsx theme={null}
import { useState } from "react";
import { Combobox, type ComboboxOption } from "@stralos-co-nz/ui";

type Rate = ComboboxOption & { rateId: string };

const rates: Rate[] = [
  { value: "Labourer", label: "Labourer", sublabel: "LAB · labour · hr", rateId: "r1" },
  { value: "Bulk excavation", label: "Bulk excavation", sublabel: "EXC · earthworks · m³", rateId: "r3" },
];

function RatePicker() {
  const [query, setQuery] = useState("");

  return (
    <Combobox<Rate>
      placeholder="Search rates…"
      value={query}
      options={rates}
      onValueChange={setQuery}
      onSelect={(rate) => {
        setQuery(rate.value);
        // also write rate.rateId or any other caller field here
      }}
    />
  );
}
```

## Externally-owned clear

Supplying `onClear` places a × button beside the chevron. The component never clears anything itself — it reports the intent and the caller performs the write, the same contract as `onSelect` and `onValueChange`. The button is shown exactly when `onClear` is supplied, so the caller controls visibility with the condition it already has:

```tsx theme={null}
<Combobox
  onClear={active ? handleClear : undefined}
  clearLabel="Clear filter"
  ...
/>
```

This lets the state being cleared live somewhere other than this field. A filter row whose third state is held by a chip beside the control can clear both from one handler, because the caller is the only thing that knows they are one filter.

`SelectCombobox` has the same `onClear` and `clearLabel` props, along with `unknownValueLabel` for naming a third state the choice list does not contain:

```tsx theme={null}
// The chip beside this control is doing the filtering; `value` is an opaque sentinel.
<SelectCombobox
  value={sentinelValue}
  unknownValueLabel="Unresolved category queue"
  onClear={handleChipAndFieldClear}
  clearLabel="Clear queue filter"
  choices={groupChoices}
  onSelect={handleSelect}
/>
```

`unknownValueLabel` is ignored when `value` matches a choice. Supply it conditionally from the same test the owner uses.

## Accessibility

* The input carries `role="combobox"`, `aria-expanded`, `aria-controls` (the listbox id), and `aria-activedescendant` (the highlighted option id).
* When a `SelectCombobox`, or a `Combobox` given `selectedId`, opens its list, the currently selected row is marked with `aria-selected="true"` and a tick, and the list scrolls to bring it into view (see [Chosen row on open](#chosen-row-on-open)). This means a user opening a long list to change a value sees the current choice rather than the top of the list.
* The dropdown is portalled to escape scroll containers and stacking contexts, and is placed at `zIndex={2000}` so it clears dialogs and sticky section headings. Both the positioner and its content element carry `zIndex={2000}` — setting only the positioner is overwritten to `auto` by Chakra's floating-UI recipe, which would leave the panel underneath sticky headings.
* When the component is rendered inside a dialog (`role="dialog"` or `role="alertdialog"`), the dropdown is portalled into the dialog's own content element rather than the document body. This keeps the list in the accessibility tree: a modal hides all body-level elements outside itself from assistive technology, so a list portalled to the body would be unreachable to screen readers even though it is visible on screen. The portal target is resolved automatically from the DOM — no prop is required.
* The combobox is a single tab stop: the input receives focus, while dropdown rows are not focusable and keyboard users reach them through arrow keys.
* `busy={true}` sets `aria-busy="true"` and `readOnly` so assistive technology knows input is temporarily paused without losing the focused element.
* `data-suggestion-count` on the input is set to `options.length` so a test can wait for options to have landed before asserting.

## Async loading

Pass `loadOptions` to fetch rows from a server as the user types. The component handles debouncing, stale-response cancellation, loading state, and retries.

```tsx theme={null}
<Combobox
  placeholder="Search addresses…"
  value={query}
  options={[]}
  onValueChange={setQuery}
  onSelect={(option) => setQuery(option.value)}
  loadOptions={(q) => geocode(q)}
  initialQuery={null}
  queryDebounceMs={1000}
  emptyMessage="No results."
  errorMessage="Address lookup failed."
/>
```

While rows are loading, the field's drop arrow is replaced by a spinner and the existing rows stay visible. The panel only blanks between a clear and the first response, not between every keystroke.

A rejection is a distinct state, not an empty result. The panel shows `errorMessage` above a **Retry** button rather than showing `emptyMessage` — a dead service reads differently from a genuine empty answer.

| Prop | Type | Default | Description |
| - | - | - | - |
| `loadOptions` | `(query: string) => Promise<O[]>` | — | Fetches rows for the current query. Called after `queryDebounceMs` of idle typing, and immediately when the field is cleared. |
| `onQueryChange` | `(query: string) => void` | — | Receives the debounced query for callers that manage their own row state. Fires on the same schedule as `loadOptions`. |
| `queryDebounceMs` | `number` | `300` | Milliseconds of idle typing before a query is issued. Address lookups benefit from a longer value such as `1000`. |
| `initialQuery` | `string \| null` | `""` | The query issued on mount. The default `""` fetches the first page immediately. Pass `null` to skip the initial request — appropriate for metered third-party endpoints where a request before anyone has typed is wasteful. |
| `reloadKey` | `string \| number` | — | Re-issues the current query when changed. Use this when the loader depends on something other than the query text, such as a category filter or a project that changed underneath. |
| `loadingMessage` | `ReactNode` | — | Shown in the panel while the first rows are loading. Later loads keep the previous rows visible instead. |
| `errorMessage` | `ReactNode \| ((error: unknown) => ReactNode)` | `"Couldn't load results."` | Shown when a load rejects. Pass a function to render a message based on the rejection itself — see [Error messages](#error-messages). |
| `retryLabel` | `string` | `"Retry"` | Label for the retry button shown alongside `errorMessage`. |

### Error messages

`errorMessage` accepts a plain node or a function of the rejection. Use the function form when different errors have different meanings for the user:

```tsx theme={null}
<Combobox
  loadOptions={(q) => geocode(q)}
  errorMessage={(error) => {
    if (error instanceof GeocoderError && error.code === "OVER_QUERY_LIMIT") {
      return "Search limit reached. Try again shortly.";
    }
    return "Address lookup failed. Check your connection.";
  }}
/>
```

The rejection arrives exactly as thrown, typed as `unknown`. Narrow it to your error type before accessing specific fields. A plain string or node still works exactly as before — the function form is additive.

## Consumer notes

* Do not use the browser's native `<datalist>` alongside this component. The component sets `autocomplete="off"` and does not attach a `list` attribute.
* Sizing props (`flex`, `minW`, `maxW`, `w`) belong on the `Combobox` element, not inside a wrapper. They are forwarded to the Chakra `Root` element.
* When `id` is absent, option identity is `value`. Supply `id` only when multiple rows can share the same `value`.
* The suggestion panel is lazily mounted: it does not exist in the DOM until the list is opened for the first time, and it is removed from the DOM when the list closes. Tests that query the panel or its rows must open the list first.
* The component is exported in the `@stralos-co-nz/ui` barrel. Each remote pins `ui` exactly and bundles its own copy, so a remote picks up a change by bumping its pin.
* `loadOptions`, `onQueryChange`, `onClear`, and `size="2xs"` are optional props.

***

## SelectCombobox

`SelectCombobox` is the suite's standard replacement for `<NativeSelect>` at any site that chooses a single value from a known, fixed set. It is a first-class export of `@stralos-co-nz/ui`.

```tsx theme={null}
import { SelectCombobox, type SelectChoice } from "@stralos-co-nz/ui";
```

### When to use it

Use `SelectCombobox` instead of bare `Combobox` when:

* The field picks one value from a closed set (replacing a `<select>` or `NativeSelect`).
* Typing should only filter the list, never create a new value.

Use plain `Combobox` when the field also accepts free text.

### Choice type

```ts theme={null}
type SelectChoice<V extends string = string> = {
  value: V;          // the token stored when this choice is picked
  label: string;     // what the row reads, and what the closed field shows
  group?: string;    // group heading; ungrouped rows appear first
  sublabel?: string; // muted second line — a code or qualifier
  trailing?: string; // right-aligned figure that must never be clipped, e.g. a price
};
```

Callers may extend `SelectChoice`. The whole choice object comes back from `onSelect`.

### Props

| Prop | Type | Default | Description |
| - | - | - | - |
| `value` | `string` | Required | The stored token. Widened to `string` because "nothing chosen" and "a value the list no longer offers" are both valid states. |
| `choices` | `C[]` | Required | The rows to offer. |
| `onSelect` | `(choice: C) => void` | Required | Fires when a row is chosen. Receives the whole choice object. |
| `showDropArrow` | `boolean` | Derived (`true`) | A chevron at the trailing edge. Derived from `allowCustomValue`, which this component pins to `false`, so the arrow is always on. Pass `false` only to suppress it deliberately. |
| `searchGroups` | `boolean` | `true` | Whether typed text also matches a row's group heading. Set `false` where the heading is a ranking or bucket rather than a property of the row — for example, "Most used" — so that typing "st" does not list every row filed under "All substances" instead of the one named Stone. |
| `searchTokens` | `boolean` | `true` | Whether typed text also matches a row's stored token. Set `false` when the token is an opaque key with a shared prefix, such as `nonbulk:<uuid>`, where every row would otherwise match "non", "bulk", or "nonbulk" and the list would never narrow. Keep the default for human-readable tokens such as unit symbols or status enums. |
| `unknownValueDisplay` | `"token" \| "blank"` | `"token"` | What to show when `value` matches no choice. `"token"` shows the stored value (correct for unit symbols or status enums). `"blank"` shows nothing (correct for opaque server keys like `c-7f3a91`). The value itself is always held and committed untouched. The displayed unknown-value text is also passed as the hover title when not typing. |
| `unknownValueLabel` | `string` | — | Label for a valid state that is not in `choices`, such as an unresolved-category filter. Ignored when `value` matches a choice. |
| `pinHeldValue` | `boolean` | `false` | When `true` and `value` holds a token that is no longer in `choices`, that value is pinned as a marked first row in the open list so the user can see what is currently selected even though the list no longer offers it. Picking the pinned row changes nothing. Leave this off when the held value is a sentinel owned by an external control such as a filter chip. |
| `trailing` (on `SelectChoice`) | `string` | — | A right-aligned figure placed at the end of the option row that never truncates — the label ellipsises instead. While the field is closed and showing the committed choice, `trailing` appears at the field's end before the chevron. The input's `title` attribute contains the full value, e.g. `"Internal / All · $2,688.00"`, unless the caller passes their own `title`. A control without `trailing` on any choice renders identically to before. |
| `onClear` | `() => void` | — | Adds a clear button and reports the clear intent to the caller. |
| `clearLabel` | `string` | `"Clear"` | Accessible name for the clear button. Name it for what it clears when a page has several comboboxes. |
| `onCreate` | `(query: string) => void` | — | Shows a `Create "X"` row while the user has typed something that is not already an exact match, and calls this with the trimmed query when it is picked. The row is filed under a "Create" heading so it always renders after the real matches. `onSelect` is never called for it. See [Inline record creation](#inline-record-creation). |
| `createEntries` | `ReadonlyArray<{ label: string; onCreate: () => void }>` | — | Standing create rows shown regardless of what is typed, each with its own handler. Use for a "Create new supplier…" entry that must be discoverable before anyone types. `onSelect` is never called for these rows either. See [Inline record creation](#inline-record-creation). |
| `createLabel` | `(query: string) => string` | `Create "${query}"` | Customises the typed-create row's label. Use to name the record type: `"Create supplier "${name}""`. |
| `placeholder` | `string` | — | Hint shown on an empty, unfocused field. Place a "Select a …" prompt here rather than as a choice row, so it can never be picked. |
| `ariaLabel` | `string` | — | Accessible name via `aria-label`. Use only when no visible label already names the control. Do not combine with `id` — `aria-label` replaces the label's text as the accessible name. |
| `id` | `string` | — | Stable id for the input. Not needed when the control is inside a `FormField` — the field mints and wires its own id automatically. |
| `required` | `boolean` | `false` | Native form-validation guard, equivalent to `<select required>`. |
| `disabled` | `boolean` | `false` | Permanent refusal: no permission, or a parent choice that rules this one out. |
| `busy` | `boolean` | `false` | Temporary refusal. Keeps focus, unlike `disabled`. |
| `emptyMessage` | `ReactNode` | `"No matches"` | Shown when a search query matches nothing. |
| `size` | `"2xs" \| "xs" \| "sm" \| "md" \| "lg"` | — | Control size. |
| `width` | `string` | — | Fixed width for a flow row or toolbar filter. |
| `maxWidth` | `string` | — | Maximum width. |

### Trailing figures

Set `trailing` on a `SelectChoice` to display a right-aligned figure — typically a price — that is never truncated in the option row. The label ellipsises to make room instead.

```tsx theme={null}
const choices = [
  { value: "internal-all", label: "Internal / All", trailing: "$2,688.00" },
  { value: "external-plant", label: "External / Plant", trailing: "$1,540.00" },
];
```

While the field is closed and the committed choice has a `trailing`, that value appears at the field's end before the chevron. The input grows its end padding to match so that a long label ellipsises up to the price rather than underneath it. The input's `title` attribute holds the concatenated display value (e.g. `"Internal / All · $2,688.00"`) so the full text is accessible on hover. A caller-supplied `title` takes priority.

The `trailing` text is searched as its own filter clause, independently of other filter settings. Typing a price fragment finds the matching row even if `searchTokens` is off.

A choice without `trailing` renders identically to before.

### Filtering

Filtering is accent-insensitive: typing `Ngati` finds `Ngāti`. By default the match runs across `label`, `sublabel`, the stored `value` token, the `group` heading, and `trailing`. Including the group heading matters for lists where the distinguishing name is only on the heading — for example, a catalogue grouped by supplier where each row is a quarry site name. Without group matching, a user searching for the supplier name would get no results, and the only workaround would be to repeat the heading as a sublabel on every row.

Use `searchGroups={false}` where the heading is a ranking or bucket rather than a property of the row ("Most used", "All substances"). In those lists the heading text is not what a row reads, so letting it match would make "st" list every row filed under "All substances" instead of the one named Stone.

Use `searchTokens={false}` when the stored token is an opaque key with a shared prefix. The calculator's rate-item picker stores tokens as `nonbulk:<uuid>`, so without this opt-out every row matched "non", "bulk", and "nonbulk", and the list never narrowed — behaviour that is indistinguishable from a field that does not accept typing at all. For human-readable tokens such as unit symbols or status enums the default is correct: someone who types `plant_vehicles` can still find Plant/Vehicles.
While no search is in progress the full list is shown, the same as a native select opening on all its options.

### Unknown values

When `value` holds a token that matches no choice, `SelectCombobox` does not fall back to showing the first option (the native `<select>` behaviour). It either shows the token or shows nothing, depending on `unknownValueDisplay`. The value itself is always preserved.

### Usage

```tsx theme={null}
import { SelectCombobox, type SelectChoice } from "@stralos-co-nz/ui";

type Unit = SelectChoice<string> & { unitId: string };

const units: Unit[] = [
  { value: "m2", label: "Square metre", unitId: "u1" },
  { value: "m3", label: "Cubic metre", unitId: "u2" },
  { value: "ea", label: "Each", unitId: "u3" },
];

function UnitPicker({ value, onChange }: { value: string; onChange: (unit: Unit) => void }) {
  return (
    <SelectCombobox<Unit>
      placeholder="Select a unit…"
      value={value}
      choices={units}
      onSelect={onChange}
    />
  );
}
```

### Inline record creation

`SelectCombobox` supports creating records without leaving the form, using two shapes that serve different purposes.

**`onCreate` — typed-text create row**

Pass `onCreate` to show a `Create "X"` row while the user has typed something that is not already an exact match. The row is filed under a "Create" heading so it always renders after the real matches, never above the row the person was looking for.

```tsx theme={null}
<SelectCombobox
  ariaLabel="Supplier"
  value={sourceId}
  choices={supplierChoices}
  onCreate={(name) => void createSupplier(name).then((source) => setSourceId(source.id))}
  createLabel={(name) => `Create supplier "${name}"`}
  onSelect={(choice) => setSourceId(choice.value)}
/>
```

* Opt-in, and inert without it. No `onCreate`, no row, and the control is otherwise unchanged.
* `onCreate` receives the trimmed query. That text is the seed for the new record's name.
* The row never reaches `onSelect`. Creating is not choosing. Perform the write, then select the created record by setting `value`.
* The row is suppressed when the typed text already exactly matches a label in the list, so it cannot offer to create a duplicate of the row above it.

**`createEntries` — standing create rows**

Pass `createEntries` for rows that must be offered before anyone types — a "Create new supplier…" entry that should be discoverable on an empty field. Each entry carries its own handler.

```tsx theme={null}
<SelectCombobox
  ariaLabel="Price source"
  value={sourceId}
  choices={sourceChoices}
  createEntries={[
    { label: "Create new supplier…", onCreate: startSupplier },
    { label: "Add our own price source…", onCreate: startInternal },
  ]}
  onSelect={(choice) => setSourceId(choice.value)}
/>
```

* Each entry's `onCreate` is called when that row is picked. Nothing is compared; there are no sentinel tokens to recognise in `onSelect`.
* Standing rows are never filtered out. An offer that disappears when you type is not standing.
* `onSelect` is not called for these rows.

**Two shapes, not one**

`onCreate`'s row exists to carry typed text into the new record; with nothing typed there is nothing to carry, and `Create ""` is not an offer. `createEntries` is for standing offers that must be discoverable before anyone types. Use both on the same control when they mean different actions — typing "Fulton" can reasonably offer `Create "Fulton"` alongside "Add our own price source…".

### Migration from NativeSelect

1. Replace `<NativeSelect>` (or a bare `<select>`) with `<SelectCombobox>`.
2. Map existing `option` entries to `SelectChoice` objects (`value` → stored token, `label` → display text).
3. Replace the `onChange` handler with `onSelect`, which receives the whole choice object.
4. Set `unknownValueDisplay="blank"` if the stored token is an opaque server key that should not be shown to users.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.