[Feature] Form: match shadcn Field (Base UI + vega)
What
Brings the Form components in line with shadcn's Field (Base UI base + vega style), found by running /shadcn-audit on form. shadcn has no form.tsx; field.tsx is the counterpart.
Form parts
- Every part carries shadcn's
data-slot(field,field-label,field-description,field-error…), so vega's selectors port almost verbatim. FormField:role="group", vega spacing (gap-3), red label when invalid (data-invalid), andorientation: :vertical | :horizontal | :responsive.FormFieldLabel: vega look (leading-snug, dims through a disabled field) and the choice-card style when it wraps aFormField.FormFieldHint: vega margins. The emptyFormFieldErroris always in the DOM here, so thelast/nth-last-2rules use:nth-last-child(N of :not([data-slot=field-error]:empty))to count as if it were absent.FormFieldError:<div role="alert">,font-normal, anderrors:(strings orActiveModel::Error; one renders as text, several as a list, duplicates dropped).- New parts:
FormFieldSet,FormFieldLegend(variant:),FormFieldGroup,FormFieldContent,FormFieldTitle,FormFieldSeparator.
Behavior
- New
ruby-ui--formcontroller onForm: focuses the first invalid control on submit.FormFieldcancelsinvalidto show its own message, which also cancelled the browser's focus. FormFieldcontroller: setsaria-invalidon the control anddata-invalidon the field, points the control at the hint and error witharia-describedby, and only writestextContent(CSSempty:hiddendoes the hiding). AFormFieldwithoutFormFieldErrorno longer throws; the browser shows its own bubble.
Audit
forminaudit/component_map.ymlplusaudit/fixtures/form.- New optional
fixture_shadcnkey for other shadcn components a fixture renders (Form's uses Input and Checkbox). The sandbox stamp now records the installed components, so an existing sandbox refreshes when the list changes. - Two divergences registered: client-side validation (Behavior) and the always-rendered error div (HTML).
Out of scope, left to their own audits: the Input's red border when invalid, and the Checkbox look in dark mode.
Before / after






On the fixture, every computed style per data-slot matches shadcn in light and dark; text widths differ by up to 3px because the sandbox uses Inter Variable.
Audit checklist on main
⏸ HTML Form, FormField, FormFieldLabel, FormFieldHint, FormFieldError
❌ Look FormField: div flex flex-col gap-2 (computed row-gap 8px)
❌ Look FormFieldLabel: text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 peer-aria-disabled:{cursor-not-allowed,o...
❌ Look FormFieldHint: p text-sm text-muted-foreground empty:hidden
❌ Look FormFieldError: text-sm font-medium text-destructive (computed font-weight 500)
❌ HTML FormField: <div> with no role
❌ HTML FormFieldError: always-rendered <p>, hidden by empty:hidden plus a JS-toggled hidden class
⚠️ ARIA error <p> has no role/aria-live (observed after an invalid submit: role null, aria-live null)
⚠️ ARIA input keeps no aria-invalid after an invalid submit or with a backend error (observed null)
⚠️ ARIA no aria-describedby from the input to FormFieldHint / FormFieldError (observed null)
⚠️ ARIA every docs example renders FormFieldLabel without for/id and not wrapping the input (input.labels.length = 0)
⚠️ Behavior invalid state: only the error text turns red; label stays oklch(0.145 0 0), input border stays oklch(0.922 0 0)
⏸ Behavior observed: invalid submit fills FormFieldError with the browser validationMessage (or data-value-missing etc.); typing updates it live (tooShort mes...
⚠️ Native onInvalid calls preventDefault(): suppresses the browser bubble and also the browser's focus on the first invalid control. Observed: after Save wit...
❌ Native error visibility toggled in JS (hidden class + shouldValidate flag that mimics 'after first submit'); input gets no invalid styling at all
⏸ Props no kwargs (attrs passthrough only)
✅ Animation none: no animate/transition classes in gem/lib/ruby_ui/form/*.rb or the controllers
⏸ Spec not run on main
Audit checklist on this branch
✅ HTML Form* parts carry shadcn's data-slot names; component_map.yml maps all 11 RubyUI parts to field.tsx (Form has no shadcn part)
✅ Look FormField: group/field flex w-full gap-3 data-[invalid=true]:text-destructive + orientation classes (row-gap 12px)
✅ Look FormFieldLabel: flex w-fit items-center gap-2 text-sm leading-snug font-medium, dims via group-data-[disabled=true]/field, choice-card rules when w...
✅ Look FormFieldHint: text-sm leading-normal font-normal text-muted-foreground; last/nth-last-2 margins counted without an empty FormFieldError; -mt-1.5 a...
✅ Look FormFieldError: text-sm font-normal text-destructive; list for several errors: ml-4 flex list-disc flex-col gap-1
✅ Look FormFieldSet (flex flex-col gap-6, gap-3 around a checkbox-group/radio-group child), FormFieldLegend (mb-3 font-medium, text-base/text-sm by varian...
✅ Look FormFieldContent (flex-1 flex-col gap-1 leading-snug), FormFieldTitle (label look on a div), FormFieldSeparator (-my-2 h-5, line + bg-background px...
✅ HTML FormField: <div role=group data-slot=field data-orientation=…>
✅ HTML FormFieldError: <div role=alert> always rendered, empty and hidden by empty:hidden when there is no error — intentional
✅ HTML FormFieldSet <fieldset> + FormFieldLegend <legend data-variant>; FormFieldSeparator <div data-content> with Separator role=separator
✅ ARIA FormFieldError has role=alert (implicit assertive live region)
✅ ARIA aria-invalid on the control and data-invalid on the field, set after an invalid submit or a backend error, removed when valid
✅ ARIA controller points each control at the hint and error via aria-describedby (generated ids, author ids kept)
✅ ARIA docs examples pair FormFieldLabel(for:) with the control id, or use orientation: :horizontal next to a Checkbox
✅ Behavior invalid field: label and error turn destructive through data-invalid on FormField
✅ Behavior client validation: browser validationMessage (or data-* messages) on submit, updated while typing; without a FormFieldError the browser bubble is k...
✅ Native Form controller (invalid->ruby-ui--form#focusFirstInvalid:capture) focuses the first invalid control, restoring what preventDefault on invalid cancels
✅ Native error visibility comes from CSS (empty:hidden on FormFieldError); JS only writes textContent and aria/data-invalid — native
✅ Props FormField(orientation: :vertical|:horizontal|:responsive), FormFieldLegend(variant: :legend|:label), FormFieldError(errors: [...]) with strings or ...
✅ Animation none: no animate/transition classes in gem/lib/ruby_ui/form/*.rb or the controllers
✅ Spec FormField <div role=group>, no name
✅ Spec FormFieldError <div role=alert>, rendered empty when there is no error
✅ Spec aria-invalid set only after an invalid submit or a backend error; aria-describedby points the control at hint and error
✅ Spec FormFieldSeparator: <div role=separator> (implicit horizontal), not focusable; FormFieldSet: <fieldset> + <legend>
How to test
cd gem && bundle exec rakeandcd audit && bundle exec rake./docs/form: submit the first example empty. Focus lands on the input, the label and message turn red, and the message clears as you type./docs/form: check the new Fieldset, Horizontal with description, Responsive, Choice card, Separator and Multiple errors examples.
🤖 Generated with Claude Code
Summary by cubic
Brings the Form parts in line with shadcn's field.tsx (Base UI + vega): every part now carries shadcn's data-slot, FormField supports orientation: :vertical | :horizontal | :responsive (nil or unknown values raise ArgumentError), and six new parts (FormFieldSet, FormFieldLegend, FormFieldGroup, FormFieldContent, FormFieldTitle, FormFieldSeparator) fill the gaps. Validation moves to native Constraint Validation: the FormField controller sets aria-invalid and aria-describedby, treats the field as invalid when any of its controls is (marking only those), shows the browser validationMessage in FormFieldError on submit, and a new Form controller focuses the first invalid control it can actually focus — preventDefault on invalid had cancelled the browser's focus. A FormField without a FormFieldError no longer throws, letting the browser show its native bubble. FormFieldError grows an errors: key accepting strings or ActiveModel::Error objects, rendered as text for one error and a list for several; blank messages are dropped.
Includes full shadcn-audit coverage for form: fixture, component map entry, and a new optional fixture_shadcn key so the sandbox installs components a fixture renders (Form's uses button, input, and checkbox; names are validated). The sandbox stamp now records installed components, so a sandbox refreshes when the map's component list changes — but not when it's reordered. The form fixture stays mounted on submit for the client-side checks. Two divergences are registered: client-side validation (Behavior) and the always-rendered error div (HTML).
Notes
Formnow requires theSeparatorcomponent independencies.yml.- Docs examples updated to pair
FormFieldLabelwith control ids and add Fieldset, Horizontal, Responsive, Choice card, Separator, and Multiple errors examples. - Out of scope: Input's invalid border and Checkbox dark-mode styling.