Select
A control that shows one choice and opens to offer the rest.
src/components/rahti_ui/select.rs
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
`class` is merged, not appended: a utility written here replaces the one it conflicts with and leaves the rest of the control alone. The merge is `twMerge`'s, in the browser, so unlike every other control on this page these two are unstyled until PulsePoint mounts — and on a select that shows, because `appearance-none` is one of the utilities that is missing until then, so the browser's own arrow is briefly back.
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 does not drive — a `name`, a `selected` default, a `required`. What a plain form posts, the tags render, and every control above the reactive section is one.
Where the tag stops
Twice. A binding does not cross the boundary: PulsePoint owns `value` and the events that maintain it, and takes that ownership in the scope the element is mounted in, which through a tag is the component's.
// Compiles against a scope with no `fruit`, and does nothing.
<Select value={fruit} onchange={setFruit(target.value)}>…</Select>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}