@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 toonValueChange 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:
- A caller-supplied
titleprop (takes priority always). value · trailingTextwhen a trailing figure is present (the same rule as before this change).valueTitlewhen non-empty (use this for the committed label).- No
titleattribute otherwise.
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
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
Thesize 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
PassselectedId to tell the list which row is already committed. When selectedId is set:
- The matching row is marked with
aria-selectedand 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.
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 acrosslabel, 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:
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 standardmd 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:- If the list is open, Escape closes it. The entry is unchanged. The Escape is not forwarded to the caller.
- 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.
- 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.
stopPropagation alone is insufficient because a dialog’s dismiss listener runs in the capture phase before any handler on the input.
Grouped options
Attach agroup string to any option. Options without a group are listed first, ungrouped. Groups appear in first-appearance order.
xs weight in a muted colour. Each option’s sublabel appears beneath its label in the list.
Async typeahead
SupplyloadOptions 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.
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
SetallowCustomValue={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.
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 appliesonValueChange 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
UseonAbandon — 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
SupplyingonClear 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:
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), andaria-activedescendant(the highlighted option id). - When a
SelectCombobox, or aComboboxgivenselectedId, opens its list, the currently selected row is marked witharia-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 carryzIndex={2000}— setting only the positioner is overwritten toautoby Chakra’s floating-UI recipe, which would leave the panel underneath sticky headings. - When the component is rendered inside a dialog (
role="dialog"orrole="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}setsaria-busy="true"andreadOnlyso assistive technology knows input is temporarily paused without losing the focused element.data-suggestion-counton the input is set tooptions.lengthso a test can wait for options to have landed before asserting.
Async loading
PassloadOptions to fetch rows from a server as the user types. The component handles debouncing, stale-response cancellation, loading state, and retries.
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:
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 setsautocomplete="off"and does not attach alistattribute. - Sizing props (
flex,minW,maxW,w) belong on theComboboxelement, not inside a wrapper. They are forwarded to the ChakraRootelement. - When
idis absent, option identity isvalue. Supplyidonly when multiple rows can share the samevalue. - 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/uibarrel. Each remote pinsuiexactly and bundles its own copy, so a remote picks up a change by bumping its pin. loadOptions,onQueryChange,onClear, andsize="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
UseSelectCombobox instead of bare Combobox when:
- The field picks one value from a closed set (replacing a
<select>orNativeSelect). - Typing should only filter the list, never create a new value.
Combobox when the field also accepts free text.
Choice type
SelectChoice. The whole choice object comes back from onSelect.
Props
Trailing figures
Settrailing 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.
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: typingNgati 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
Whenvalue 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. onCreatereceives 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 settingvalue. - 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
onCreateis called when that row is picked. Nothing is compared; there are no sentinel tokens to recognise inonSelect. - Standing rows are never filtered out. An offer that disappears when you type is not standing.
onSelectis not called for these rows.
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
- Replace
<NativeSelect>(or a bare<select>) with<SelectCombobox>. - Map existing
optionentries toSelectChoiceobjects (value→ stored token,label→ display text). - Replace the
onChangehandler withonSelect, which receives the whole choice object. - Set
unknownValueDisplay="blank"if the stored token is an opaque server key that should not be shown to users.

