Combobox
An autocomplete input with a filtered list of suggestions.
src/components/rahti_ui/combobox.rs
Basic
The control is an Input Group with a trigger in its trailing addon, which is exactly what shadcn's `ComboboxInput` is — so that half of the port is the class lists this library already had. The options are a prop rather than children; the last section says why they have to be.
Type to narrow the list, ↑/↓ to move the highlight, Enter to take it, Escape to close. The chevron opens the popup without stealing the caret. Every one of those is this component's own — Base UI implements them where shadcn runs, and there is no Base UI here.
Clear
shadcn's `showClear`, off unless asked for, as it is there. Clearing empties the query and the selection together and puts the caret back in the field — a clear button that leaves the field blurred is a clear button nobody can type after.
The clear button replaces the chevron rather than sitting beside it, and one utility on the trigger does it. shadcn's asks whether a clear button *exists*, which works there because Base UI renders none when there is nothing to clear; here it always exists and hides itself, so the test became `group-has-[[data-slot=combobox-clear]:not([hidden])]` — the question shadcn's was actually asking. `hidden` is a real boolean attribute, so a binding on it does drop out of the DOM.
Auto highlight
shadcn's `autoHighlight`: put the highlight on the first match as soon as the list narrows, so Enter picks it without an arrow key first. Off by default, because a highlight the reader did not ask for is a selection they did not mean.
The highlight also has to survive the list shrinking under it. An effect pulls it back to the last row when the filter cuts the list short, and drops it entirely when nothing matches — otherwise Enter would take a row that is no longer there.
Groups
shadcn nests `ComboboxItem`s inside a `ComboboxGroup` with a `ComboboxLabel`. Here `group` is a field on the option, because the items are data — and the rendered result is the same heading, rule and run of rows. Bramley is disabled: shown, skipped by the arrow keys, and not selectable.
A heading is drawn above the first option that carries it *after* filtering, which is why the decision is made in the script rather than in the markup: whether a row starts its group is a question about the filtered list, not the original one. Type “n” and watch Apples disappear as a heading along with its last match.
Multiple
shadcn's `multiple` swaps the control: a single-select Combobox is an Input Group with a trigger, and a multiple-select one is a `ComboboxChips` box — the field's border and focus ring drawn around a row of tags with a bare input at the end of them. The popup, the filter and the keyboard are unchanged.
Backspace on an empty query removes the last chip, which is the one keyboard affordance a chips field is judged on. Picking an option that is already selected removes it, so the list doubles as the way back out.
States
`disabled` reaches the input and both addon buttons, so there is no way in from the keyboard or the mouse. `invalid` is the half a screen reader is told as well as the half a reader sees.
`aria-invalid` is written on the control and the field reddens around it — `has-aria-invalid:` on the chips box, and the Input Group's own rule in the single case. Both are presence tests and both work, because this one is a server attribute rather than a binding.
As a form posts it
The selection is a run of real `<input type="hidden">` fields, one per value, all under one name — so `?fruit=apple` and `?tag=rust&tag=axum` come out of a plain GET with no serialisation step and nothing to parse on the server that a `<select multiple>` would not also need. Submit it and read the query string.
Keyboard
The whole of it is `onkeydown` on the input and one `active` index in the script. `aria-activedescendant` follows the highlight to the row's id, which is what makes a screen reader read the option without the focus ever leaving the field — the arrangement `role="combobox"` is specified around.
↓ / ↑ move the highlight, wrapping at both ends Home / End first and last row, while the popup is open Enter take the highlighted row Escape close, and drop the highlight Backspace remove the last chip, on an empty multiple query Tab leave — and the field snaps back to the selection
The query is committed on blur rather than on every keystroke, so a half-typed word is never thrown away mid-edit. A single-select Combobox snaps back to the selected label on the way out; a multiple one just empties the search, because its selection is in the chips.
The port
Fourteen exports become one component and one option type. Nine of shadcn's parts are drawn from the inside and keep their class lists and their `data-slot`s, so a page's own CSS still finds them. `ComboboxCollection` and `ComboboxValue` are Base UI plumbing and are not ported; the portal, the floating positioner and the exit animation are not either, which is why the popup always opens below and is sized `w-full` where shadcn reads `--anchor-width`.
// shadcn // rahti-ui
<Combobox> <Combobox items=@{…} />
<ComboboxInput /> (the Input Group, drawn inside)
<ComboboxContent> (the popup, drawn inside)
<ComboboxList> (the listbox, drawn inside)
<ComboboxItem value=…> ComboboxOption::new(value, label)
<ComboboxGroup><ComboboxLabel> .group("Apples")
<ComboboxEmpty>Nothing</> .empty_text("Nothing")
// the builder, when the options are computed
combobox()
.name("fruit")
.items(rows.iter().map(|r| (r.id.clone(), r.name.clone())))
.show_clear(true)
.render()The boundary
This is the only component in the library with no child tags, and the table above is the reason. A Combobox's parts are not decoration — an item has to know whether it is highlighted, the popup whether it is open, the empty line whether anything matched. Written as tags, every one of those bindings would compile against its own scope and find nothing, and the failure is silent: an uncompiled binding stays on the element as literal text. So the root owns the state and draws every part itself, and the options arrive as data. The cost is real — an option is a value and a label, where shadcn's takes children.
a plain element in the root's html! reads the root's state a <template pp-for> row reads the root's state a nested #[component] tag does not — pp-component is a scope a <>…</> fragment does not — the comment pair is a scope