Input Group
The border and the focus ring around a row, so an icon, a unit or a button can sit inside the field.
src/components/rahti_ui/input_group.rs
Anatomy
A group is a bordered row; the control inside gives up its own border, rounding, shadow and focus ring, and the group draws all four around the whole thing. Write the addon after the control — Tab follows the DOM, and `align` is what moves the paint.
Align
`inline-start` and `inline-end` sit beside the control; `block-start` and `block-end` sit above and below it, take the full width, and turn the group into a column — that is `has-[>[data-align=block-end]]:flex-col`, a question the group asks about its own direct children. Use the inline pair with an input and the block pair with a textarea.
Icon and text
An icon inside an addon needs no size: `[&>svg:not([class*='size-'])]:size-4` gives it one, and stands aside the moment the caller writes their own. `InputGroupText` is the muted run beside it — a unit, a prefix, a currency.
Button
`ghost` and `xs` unless said otherwise, and `type="button"` always — a clear-field button that submitted the form around it would be a bug in every case. The addon's negative margin (`has-[>button]:mr-[-0.3rem]`) pulls the button's padding back so its edge, not its padding, lines up with where an icon would have been.
Textarea
`resize-none`, because the group draws the border and a corner grip would drag the textarea out of it. The box grows instead: `field-sizing-content` on the textarea and `has-[>textarea]:h-auto` on the group, working together — type several lines into the first one and watch the border follow.
States
None of these is a prop on the group. It rings because a control inside it is focused, reddens because one is `aria-invalid`, and dims because one is `disabled` — three `has-` questions in the group's own class list. Tab into the first one to see the ring land on the whole row rather than on the input.
A control this page owns
The group finds its control by `data-slot="input-group-control"` and nothing else, so any element carrying that slot and the matching class list is one. This is the form to reach for when a control needs a `class` override: the two control components pay a fragment for one — an `<input>` is void and a `<textarea>`'s children are its value, so neither can hold the `<script>` a mounted block needs — and a fragment is exactly what `has-[>textarea]` cannot see through.
Attributes
`attrs` carries the rest of the spread — server-side values with no braced expression in them, and names a Rust identifier cannot spell. It is applied last, so it can also take one of the component's own attributes away: an addon whose contents handle their own clicks can drop the focus handler with `unset("onclick")`.
Class overrides
With no override the class list is a finished string and the part is styled by the server, JavaScript or no JavaScript. With one, `class` becomes a `twMerge` binding — literal text until PulsePoint mounts. `max-w-sm` on a group is the common case, and `border-t` on a block addon is the other.
Why an override is not a wrapper
A mountable block needs a `<script>`, and a script written beside the element makes the block's root a fragment — which the runtime raises into a `<pp-fragment>` at mount. Almost every rule in this family is a `has-[>…]` or a `[&>input]` question about **direct children**, and a wrapper switches them off.
So the four parts that can hold a script put it inside the element, as its first child. A `<script>` is `display: none`, so it is not a flex item and takes no gap, and the runtime removes it once the block is mounted.
`InputGroupInput` and `InputGroupTextarea` cannot: an `<input>` is void, and anything inside a `<textarea>` is its value. Those two fall back to a fragment, so an override on them costs the group's `>` rules — the section above shows what to write instead.
What this port changes
Three deltas, and the first is the only one that changes a number. Everything else — the addon paddings, the negative margins, the ring, the four `has-` state rules — is shadcn's string, unchanged.
| shadcn | rahti-ui | Why |
|---|---|---|
| h-8 rounded-lg | h-9 rounded-md | Those two numbers are shadcn's *Input's* height and radius. This library's Input is `h-9 rounded-md` — it is a port of the earlier shadcn Input, and every control here matches it. A 32px group would be the only 32px control in the library and would sit a pixel-and-a-half short of a Button beside it. |
| onClick={…} (React) | onclick="…" (inline) | The addon's click-to-focus is three lines of DOM, and `cursor-text` is the promise it keeps. Written as a plain inline handler it carries no braces, so it is not a PulsePoint binding and an addon never needs a mounted scope for it. PulsePoint claims every `on*` attribute on hydration, so the handler becomes a listener and the attribute disappears — a handover, not a loss. All three of shadcn's behaviours hold on both sides of it. |
| sm: "" | h-8 gap-2 rounded-md px-2.5 | shadcn spells `sm` as the empty string, meaning "whatever the Button already is" — and their Button's default is `h-8 px-2.5`. Ours is `h-9`, so `sm` is written out as the `h-8` button this library actually has. The other three sizes are shadcn's layer verbatim. |
What is not ported
`InputGroupAddon`'s click-to-focus queries `input`, exactly as shadcn's does — which means an addon beside a textarea is not clickable-to-focus in either library. And the Dropdown and Spinner examples on shadcn's page compose components this library has not ported yet; the Input Group needs nothing from them, and they will drop into an addon when they land.
`in-data-[slot=combobox-content]:` is carried verbatim and does nothing today. It stops a group inside a Combobox popup from drawing its own focus ring — the rule is already here for when there is one.
A field the page reads
A braced expression written between a tag's tags is compiled in the block it was written in, and children handed to a component tag were written somewhere else. So a group whose state moves belongs on markup this page owns — `input_group_variants` and `input_group_input_variants` return the same class lists the components render, and `data-slot="input-group-control"` is what the group's focus and invalid rules look for.
Matching: {query === "" ? "everything" : query}
A password the page reveals
The button inside the field swaps the input's `type`, which is the one thing a reveal control has to do. The addon's own click handler steps aside for it — `event.target.closest('button')` returns early — so the button gets the click and the caret stays where it was.