[Feature] Form: match shadcn Field (Base UI + vega)

Merged
CI
2d ago
+1095-101

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), and orientation: :vertical | :horizontal | :responsive.
  • FormFieldLabel: vega look (leading-snug, dims through a disabled field) and the choice-card style when it wraps a FormField.
  • FormFieldHint: vega margins. The empty FormFieldError is always in the DOM here, so the last/nth-last-2 rules 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, and errors: (strings or ActiveModel::Error; one renders as text, several as a list, duplicates dropped).
  • New parts: FormFieldSet, FormFieldLegend(variant:), FormFieldGroup, FormFieldContent, FormFieldTitle, FormFieldSeparator.

Behavior

  • New ruby-ui--form controller on Form: focuses the first invalid control on submit. FormField cancels invalid to show its own message, which also cancelled the browser's focus.
  • FormField controller: sets aria-invalid on the control and data-invalid on the field, points the control at the hint and error with aria-describedby, and only writes textContent (CSS empty:hidden does the hiding). A FormField without FormFieldError no longer throws; the browser shows its own bubble.

Audit

  • form in audit/component_map.yml plus audit/fixtures/form.
  • New optional fixture_shadcn key 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

Default
Invalid after submit
Invalid after submit, dark
Disabled
Fixture, light
Fixture, dark

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 rake and cd 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

  • Form now requires the Separator component in dependencies.yml.
  • Docs examples updated to pair FormFieldLabel with 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.