Field
The layout and the wiring around a control — label, hint, error, and the group that holds them.
src/components/rahti_ui/field.rs
Anatomy
A Field is a `role="group"` holding a label, a control, and whatever explains or corrects them. Nothing here positions anything: the label sits above because `flex-col` is the vertical orientation's layer, and the control fills the column because `*:w-full` sizes the Field's direct children.
We only use it to sign you in.
Around any control
A Field wraps whatever the library renders — the port is the layout, not the input. The only thing each one needs is an `id` for the label's `for` to name.
Shown on your public profile.
Slide to set a ceiling.
Orientation
`vertical` stacks and stretches; `horizontal` puts the control at the end of the row and gives the label the leftover width. A horizontal field holding a FieldContent is the third case: the row is taller than one line, so it aligns to the top and the box is nudged one pixel down onto the first line's optical centre.
You can withdraw consent at any time from your account settings.
FieldSet, FieldLegend, FieldGroup
A real `<fieldset>`, so `disabled` on it disables every control inside — HTML's own behaviour, not a class. The legend must be the fieldset's first child, and a description written straight after it tucks itself up under it: that is `[[data-variant=legend]+&]:-mt-1.5`, and it needs the two to be adjacent siblings.
Choice cards
A FieldLabel with a whole Field nested *directly* inside it becomes a bordered, rounded, full-width card, and it highlights while the control inside is checked. Both are `has-[>…]` rules, and the `>` is not decoration: a div between the label and the Field turns them off. The title inside is a FieldTitle rather than a second FieldLabel — the label already names the control, and two would make a screen reader read the name twice.
FieldSeparator
The line is drawn edge to edge and the text is painted over the middle of it in the page's background colour — which is how the gap appears without anyone measuring the text. `-my-2` eats the group's gap on both sides, so a separator reads as a division rather than as a third item in the list.
Responsive orientation
`orientation="responsive"` is vertical until the FieldGroup around it is wide enough, then horizontal. The breakpoint is a **container** query measured against that group — `@container/field-group` — not against the window, so the same field is stacked in a sidebar and side by side in a page without either knowing where it was placed. Without a FieldGroup above it, `@md/field-group:` never fires and a responsive field is a vertical one. The two panels below are the same markup at two widths.
max-w-xs — stacked
max-w-xl — side by side
Validation and errors
`invalid` on the Field turns the whole group red at once — that is `data-[invalid=true]:text-destructive`, which is why the flag is written on the Field and not on each part. The control still needs its own `aria-invalid`: this is what a reader sees, that is what a screen reader is told. A FieldError with nothing to say renders **nothing at all**, because `role="alert"` on an empty box is a region that can announce silence.
- At least 8 characters.
- At least one digit.
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.
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, so an overridden part carries no utilities at all until then. Every override on this page pays that; `max-w-sm` on a FieldGroup is the common case.
Why an override is not a wrapper
A rahti-ui component normally renders its element inside a fragment, with the `<script>` beside it, when it needs a scope — and the runtime raises that fragment into a real `<pp-fragment>` at mount. A `<pp-fragment>` is `display: contents` — invisible to layout and to a descendant selector, but not to `>`.
This family is written almost entirely in `>` and sibling selectors, so a wrapper would silently switch them off: `*:w-full` would size the wrapper instead of the label inside it, and the label would lose its width the moment PulsePoint mounted. So a Field part is always one literal element, and the `<script>` goes *inside* it — first child everywhere except a FieldSet, whose `<legend>` has to come first.
And where an override should not go
A mounted block re-renders what its `<slot />` holds, and adjacent children carrying the same `pp-component` — which two `<Field>` tags always do, since the name is the function's — come back mis-keyed: one duplicated, the last dropped. So a width belongs on a plain wrapper around a FieldGroup rather than on the group itself, which is `w-full` and fills it anyway. Every example on this page is written that way.
It is the runtime's behaviour rather than this component's — any repeated component tag under any mounted block does the same — and it shows up here first only because a layout wrapper with a width on it is the most natural thing to write. An override on a part with one child, or whose children are all different components, is unaffected.
A `<script>` is `display: none`, so it is not a flex item, takes no gap, and `*:w-full` on it means nothing. The one thing it could cost is a position — a `nth-last-2:` or `last:` written against the children of an overridden part would count it — and it does not, because PulsePoint takes the script out of the DOM once the block is mounted. It is a served-markup detail, not a rendered one.
What this port changes
shadcn's Base UI Field asks three questions a server-rendered native control cannot answer. Each is translated the way the rest of this library translates them — see the Checkbox, which reads `data-[state=checked]:` as `checked:`.
| shadcn | rahti-ui | Why |
|---|---|---|
| has-data-checked: | has-[:checked]: | Base UI marks a checked box with a `data-checked` attribute; a native `<input type="checkbox">` answers `:checked`. Same declarations, same highlight on a selected card, native selector. |
| [&>[role=checkbox],[role=radio]]:mt-px | [&>input[type=checkbox],&>input[type=radio]]:mt-px | Radix renders a `<button role="checkbox">`; this library renders the input HTML already has. The optical nudge is the same one. |
| data-horizontal: (a @custom-variant) | data-horizontal: (the same one, in globals.css) | Not Tailwind's. shadcn's Base UI style defines `data-horizontal` as `&:where([data-orientation="horizontal"])` and writes its components in the short spelling, so this project now defines it too — copied, beside the `dark` variant that was already there. Without it the separator's rule matches nothing and `absolute inset-0 top-1/2` draws a ten-pixel bar instead of a line. |
| `<Separator />` inside FieldSeparator | SEPARATOR_RULE, inline | This library has not ported a Separator yet. The class list is shadcn's, kept whole and alone so there is exactly one thing to delete when one lands. |
What is not ported
Standard Schema validators. shadcn's FieldError accepts whatever Zod, Valibot or ArkType hands it and reads `.message` off each entry; this takes the messages themselves, `&[&str]`, and applies the same rules — children win, duplicates are said once, one message is text and several are a list. Validation is the server's here, and it already has the strings.
A field the page validates
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 Field whose state moves belongs on markup this page owns — the `*_variants` functions return the same class lists the components render, and the `data-slot` attributes are what the family's other selectors look for.
We only use it to sign you in.
`data-invalid` is bound as a string rather than as a boolean, and the difference is not cosmetic: PulsePoint writes a `true` as a bare boolean attribute — `data-invalid=""` — and the selector Tailwind compiles asks for `[data-invalid="true"]` by value. The control's `aria-invalid` is a boolean and wants the other spelling.
A field the page disables
`data-disabled` on the Field is what the label reads — `group-data-[disabled=true]/field:opacity-50` — and it is presentation only. The control below carries its own `disabled`, because a dimmed field that still types is worse than no dimming.