Skip to main content
@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.

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

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

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: "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:
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.

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.
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.
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.
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.
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

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:
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:
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). 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.
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.

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:
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.

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

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

Props

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.
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

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.
  • 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.
  • 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.