Select
A control that shows one choice and opens to offer the rest.
src/components/rahti_ui/select.rs
Installation
Install this component in your Rahti 0.0.58 application. Class conflicts are resolved with the existing frontend `twMerge`; no Rust class-merging dependency is needed.
Basics
Two selects: the first opens on a placeholder, the second on the option marked `selected`. Open either one and the list is the browser's — on a phone it is the OS picker, and typing jumps to a match. None of that is this component, and none of it needs JavaScript, which is the whole of why this is a `<select>` rather than shadcn's button-and-popover.
Sizes
shadcn's two, kept the way shadcn keeps them: the size is a `data-size` attribute, and `data-[size=default]:h-9 data-[size=sm]:h-8` in the class list reads it. Both utilities are always present, under different prefixes, so there is nothing for a merge to resolve.
States
The placeholder is muted, and it is muted by a CSS question rather than by a React state: `has-[option[data-placeholder]:checked]:text-muted-foreground` asks the control which option is chosen. `.invalid(true)` writes `aria-invalid`, and the `aria-invalid:` utilities do the rest — the styling and the accessible state are the same fact, spelled once.
Groups and a separator
shadcn's `SelectGroup` and `SelectLabel` are one `<optgroup label="…">` here, because HTML has no separate label element inside a select. `SelectSeparator` is an `<hr>`, which recent Chrome, Safari and Firefox draw and older browsers ignore — the right failure, since a rule carries no meaning a reader would miss. Both are drawn by the browser, so neither takes shadcn's class list.
With a Label
The two ways HTML associates a label with a control, and both make the text click through to the select. `Label`'s class list is `flex items-center gap-2`, so stacking a label over a field is an override of one utility.
As a form posts it
One `name`, and `?country=pt` in the query string — no JavaScript, no state in the page, no hidden input beside the control to carry the value. Submit without choosing and the browser refuses: the placeholder's value is empty, and an empty value fails `required`. That pairing is the whole reason the placeholder is `value=""` rather than a label with no option behind it.
Attributes
`.attr(…)` writes anything and `.unset(…)` takes one away. The second control is `multiple`, which is HTML asking for a list box rather than a dropdown — not a shadcn prop, because Radix's Select has no such thing. Its override is worth reading twice: the height is `data-[size=default]:h-auto`, not `h-auto`, because a merge only replaces a utility with one carrying the same variant prefix, and the height in the class list is prefixed. Plain `h-auto` would sit beside `data-[size=default]:h-9` and lose.
Class override
Rust composes the component and caller class lists. When utilities conflict, use the existing frontend `twMerge` in a compiled class binding.
What this port changed
shadcn ships ten exports here. This ships five, and the difference is the whole story: a native `<select>` is Radix's Root, Trigger, Value, Portal, Content, Viewport and both ScrollButtons, and the browser draws the part those exist to build.
| shadcn | here | why |
|---|---|---|
| Select + Trigger + Value | <select> | One element. The Trigger's class list is the only part anyone can style, and it is carried here unchanged. |
| Content + Portal + Viewport | — the browser's popup | No z-index, no scroll lock, no focus trap, and no ancestor's `overflow` can clip it. On a phone it is the OS picker. |
| SelectItem | <option> | An option carries `data-slot` and nothing else: `focus:bg-accent`, the check mark and the icon slots all style a div this port does not build. |
| <ChevronDownIcon /> | background-image | A `<select>` may hold only options. Same lucide path, same 16px, same half opacity — twice, because a data URI cannot read `--foreground`. |
| data-[placeholder]: | has-[option[data-placeholder]:checked]: | Radix marks the Trigger when its Value is empty. A select has no such attribute — but it has the chosen option, and CSS can ask about it. |
| value on the Root | selected on the option | Children arrive already rendered, so a parent cannot reach into them — the same move the Radio Group makes. |
When this is the wrong component
If the list itself has to be styled — an icon per row, a check mark, a description under each label, a search box — then the answer is not a `<select>` in either library. That is a Dropdown Menu or a Combobox: a button and a popover, built out of elements that can hold anything. Both are their own components, and both are still planned.
The component tags, and their boundary
The tags are the right call for a control this page drives as much as for one it does not — a `name`, a `selected` default, a `required`. What a plain form posts, the tags render, and every control above the reactive section is one.
Through the tag
A binding crosses the boundary: a component's root attributes are evaluated where the tag was written, so `value` and the events that maintain it read and write this page's state, whether the options are SelectItem tags or plain `<option>`s.
And children handed to a tag are compiled where they were written, so a list that *is* the page's state — a `<pp-for>` over an array — wants `select_variants` on a native `<select>`, which is what the reactive section below uses.
Controlled, page scope
A select reports itself through `target.value`, and takes a value the same way. The element is written with `select_variants` rather than as a tag, because a binding is compiled in the block its element sits in and this one has to sit in this block — and its options are written here too, so `{…}` among them resolves.
Chosen: {fruit}