Form
Building forms with built-in client-side validations.
Usage
Example
Form(class: "w-2/3 space-y-6") do
FormField do
FormFieldLabel(for: "default_error") { "Default error" }
Input(id: "default_error", placeholder: "Joel Drapper", required: true, minlength: "3") { "Joel Drapper" }
FormFieldHint()
FormFieldError()
end
Button(type: "submit") { "Save" }
end
Disabled
FormField(data: {disabled: "true"}) do
FormFieldLabel(for: "disabled") { "Disabled" }
Input(id: "disabled", disabled: true, placeholder: "Joel Drapper", required: true, minlength: "3") { "Joel Drapper" }
end
Custom error message
Form(class: "w-2/3 space-y-6") do
FormField do
FormFieldLabel(for: "custom_error_message") { "Custom error message" }
Input(id: "custom_error_message", placeholder: "[email protected]", required: true, data_value_missing: "Custom error message")
FormFieldError()
end
Button(type: "submit") { "Save" }
end
Backend error
Form(class: "w-2/3 space-y-6") do
FormField do
FormFieldLabel(for: "backend_error") { "Backend error" }
Input(id: "backend_error", placeholder: "Joel Drapper", required: true)
FormFieldError { "Error from backend" }
end
Button(type: "submit") { "Save" }
end
Multiple errors
Form(class: "w-2/3 space-y-6") do
FormField do
FormFieldLabel(for: "multiple_errors") { "Username" }
Input(id: "multiple_errors", value: "j!", required: true)
# e.g. FormFieldError(errors: user.errors[:username])
FormFieldError(errors: ["is too short (minimum is 3 characters)", "only allows letters and numbers"])
end
Button(type: "submit") { "Save" }
end
Without a FormFieldError, a FormField leaves the message to the browser, which shows its native bubble next to the control. If a form mixes fields with and without FormFieldError, focus goes to the first field with a bubble, which can come after the first invalid field.
Checkbox
Form(class: "w-2/3 space-y-6") do
FormField(orientation: :horizontal) do
Checkbox(id: "accept_terms", required: true)
FormFieldLabel(for: "accept_terms") { "Accept terms and conditions" }
end
Button(type: "submit") { "Save" }
end
Fieldset
FormFieldSet(class: "w-2/3") do
FormFieldLegend { "Address information" }
FormFieldHint { "We need your address to deliver your order." }
FormFieldGroup do
FormField do
FormFieldLabel(for: "fieldset_street") { "Street address" }
Input(id: "fieldset_street", placeholder: "123 Main St")
end
FormField do
FormFieldLabel(for: "fieldset_city") { "City" }
Input(id: "fieldset_city", placeholder: "New York")
end
end
end
Horizontal with description
One email a month, no spam.
FormField(orientation: :horizontal, class: "w-2/3") do
Checkbox(id: "horizontal_newsletter")
FormFieldContent do
FormFieldLabel(for: "horizontal_newsletter") { "Subscribe to the newsletter" }
FormFieldHint { "One email a month, no spam." }
end
end
Responsive
Side by side on wide containers, stacked on narrow ones.
FormFieldGroup do
FormField(orientation: :responsive) do
FormFieldContent do
FormFieldLabel(for: "responsive_name") { "Name" }
FormFieldHint { "Side by side on wide containers, stacked on narrow ones." }
end
Input(id: "responsive_name", placeholder: "Joel Drapper")
end
end
Choice card
FormFieldGroup(class: "w-2/3") do
FormFieldLabel(for: "plan_starter") do
FormField(orientation: :horizontal) do
FormFieldContent do
FormFieldTitle { "Starter" }
FormFieldHint { "For small teams getting started." }
end
RadioButton(id: "plan_starter", name: "plan", value: "starter", checked: true)
end
end
FormFieldLabel(for: "plan_pro") do
FormField(orientation: :horizontal) do
FormFieldContent do
FormFieldTitle { "Pro" }
FormFieldHint { "For growing teams that need more." }
end
RadioButton(id: "plan_pro", name: "plan", value: "pro")
end
end
end
Separator
FormFieldGroup(class: "w-2/3") do
FormField do
FormFieldLabel(for: "separator_email") { "Email" }
Input(id: "separator_email", type: "email", placeholder: "[email protected]")
end
FormFieldSeparator { "Or" }
FormField do
FormFieldLabel(for: "separator_phone") { "Phone" }
Input(id: "separator_phone", type: "tel", placeholder: "+1 555 0100")
end
end
Select
Form(class: "w-2/3 space-y-6") do
FormField do
FormFieldLabel(for: "form-select-fruit") { "Select" }
Select do
SelectInput(name: "fruit", required: true)
SelectTrigger(id: "form-select-fruit", class: "w-56") do
SelectValue(placeholder: "Select a fruit")
end
SelectContent do
SelectGroup do
SelectLabel { "Fruits" }
SelectItem(value: "apple") { "Apple" }
SelectItem(value: "orange") { "Orange" }
SelectItem(value: "banana") { "Banana" }
SelectItem(value: "watermelon") { "Watermelon" }
end
end
end
FormFieldError()
end
Button(type: "submit") { "Save" }
end
Combobox
Form(class: "w-2/3 space-y-6") do
FormField do
FormFieldLabel(for: "food-combobox") { "Combobox" }
Combobox(name: "food", required: true) do
ComboboxTrigger(id: "food-combobox") { ComboboxValue(placeholder: "Pick value") }
ComboboxContent do
ComboboxInput(placeholder: "Search", show_trigger: false)
ComboboxEmpty { "No result" }
ComboboxList do
ComboboxGroup do
ComboboxLabel { "Fruits" }
ComboboxItem(value: "apple") { "Apple" }
ComboboxItem(value: "banana") { "Banana" }
end
ComboboxSeparator()
ComboboxGroup do
ComboboxLabel { "Vegetable" }
ComboboxItem(value: "brocoli") { "Broccoli" }
ComboboxItem(value: "carrot") { "Carrot" }
end
ComboboxSeparator()
ComboboxGroup do
ComboboxLabel { "Others" }
ComboboxItem(value: "chocolate") { "Chocolate" }
ComboboxItem(value: "milk") { "Milk" }
end
end
end
end
FormFieldError()
end
Button(type: "submit") { "Save" }
end
Rails Integration
RubyUI Form components are plain HTML — they work with any form submission strategy. The recommended approach for Rails apps is to use form_with to generate the action URL and CSRF token, then pass explicit name / id attributes to each RubyUI input so the browser serialises them correctly. Server-side errors can be surfaced by rendering FormFieldError with content from model.errors.full_messages_for(:attr).
Minimal Rails form
# In your Phlex view, call form_with via helpers:
# form_with(url: users_path, method: :post) passes action + CSRF automatically.
#
# You can also set action and the CSRF token manually:
Form(action: helpers.users_path, method: "post", class: "w-2/3 space-y-6") do
input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
FormField do
FormFieldLabel(for: "user_email") { "Email" }
Input(
type: "email",
id: "user_email",
name: "user[email]",
placeholder: "[email protected]",
required: true
)
FormFieldError()
end
Button(type: "submit") { "Continue" }
end
Devise-style login form
# Full sign-in form mirroring Devise session[email] / session[password] params.
# Pass backend errors (e.g. "Invalid email or password") into FormFieldError.
Form(action: helpers.user_session_path, method: "post", class: "space-y-6") do
input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
FormField do
FormFieldLabel(for: "session_email") { "Email" }
Input(
type: "email",
id: "session_email",
name: "session[email]",
placeholder: "[email protected]",
autocomplete: "email",
required: true
)
FormFieldError { @error_message }
end
FormField do
FormFieldLabel(for: "session_password") { "Password" }
Input(
type: "password",
id: "session_password",
name: "session[password]",
autocomplete: "current-password",
required: true,
minlength: "8"
)
FormFieldError()
end
FormField(orientation: :horizontal) do
Checkbox(id: "session_remember_me", name: "session[remember_me]", value: "1")
FormFieldLabel(for: "session_remember_me") { "Remember me" }
end
Button(type: "submit", class: "w-full") { "Sign in" }
end
Installation
Using RubyUI CLI
Run the install command
rails g ruby_ui:component Form
Manual installation
1
Add RubyUI::Form to app/components/ruby_ui/form/form.rb
# frozen_string_literal: true
module RubyUI
class Form < Base
def view_template(&)
form(**attrs, &)
end
private
def default_attrs
{
data: {
controller: "ruby-ui--form",
action: "invalid->ruby-ui--form#focusFirstInvalid:capture"
}
}
end
end
end
2
Add RubyUI::FormDocs to app/components/ruby_ui/form/form_docs.rb
# frozen_string_literal: true
class Views::Docs::Form < Views::Base
def view_template
component = "Form"
div(class: "max-w-2xl mx-auto w-full py-10 space-y-10") do
render Docs::Header.new(title: "Form", description: "Building forms with built-in client-side validations.")
Heading(level: 2) { "Usage" }
render Docs::VisualCodeExample.new(title: "Example", context: self) do
<<~RUBY
Form(class: "w-2/3 space-y-6") do
FormField do
FormFieldLabel(for: "default_error") { "Default error" }
Input(id: "default_error", placeholder: "Joel Drapper", required: true, minlength: "3") { "Joel Drapper" }
FormFieldHint()
FormFieldError()
end
Button(type: "submit") { "Save" }
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Disabled", context: self) do
<<~RUBY
FormField(data: {disabled: "true"}) do
FormFieldLabel(for: "disabled") { "Disabled" }
Input(id: "disabled", disabled: true, placeholder: "Joel Drapper", required: true, minlength: "3") { "Joel Drapper" }
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Custom error message", context: self) do
<<~RUBY
Form(class: "w-2/3 space-y-6") do
FormField do
FormFieldLabel(for: "custom_error_message") { "Custom error message" }
Input(id: "custom_error_message", placeholder: "[email protected]", required: true, data_value_missing: "Custom error message")
FormFieldError()
end
Button(type: "submit") { "Save" }
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Backend error", context: self) do
<<~RUBY
Form(class: "w-2/3 space-y-6") do
FormField do
FormFieldLabel(for: "backend_error") { "Backend error" }
Input(id: "backend_error", placeholder: "Joel Drapper", required: true)
FormFieldError { "Error from backend" }
end
Button(type: "submit") { "Save" }
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Multiple errors", context: self) do
<<~RUBY
Form(class: "w-2/3 space-y-6") do
FormField do
FormFieldLabel(for: "multiple_errors") { "Username" }
Input(id: "multiple_errors", value: "j!", required: true)
# e.g. FormFieldError(errors: user.errors[:username])
FormFieldError(errors: ["is too short (minimum is 3 characters)", "only allows letters and numbers"])
end
Button(type: "submit") { "Save" }
end
RUBY
end
Text do
plain "Without a "
InlineCode { "FormFieldError" }
plain ", a "
InlineCode { "FormField" }
plain " leaves the message to the browser, which shows its native bubble next to the control. If a form mixes fields with and without "
InlineCode { "FormFieldError" }
plain ", focus goes to the first field with a bubble, which can come after the first invalid field."
end
render Docs::VisualCodeExample.new(title: "Checkbox", context: self) do
<<~RUBY
Form(class: "w-2/3 space-y-6") do
FormField(orientation: :horizontal) do
Checkbox(id: "accept_terms", required: true)
FormFieldLabel(for: "accept_terms") { "Accept terms and conditions" }
end
Button(type: "submit") { "Save" }
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Fieldset", context: self) do
<<~RUBY
FormFieldSet(class: "w-2/3") do
FormFieldLegend { "Address information" }
FormFieldHint { "We need your address to deliver your order." }
FormFieldGroup do
FormField do
FormFieldLabel(for: "fieldset_street") { "Street address" }
Input(id: "fieldset_street", placeholder: "123 Main St")
end
FormField do
FormFieldLabel(for: "fieldset_city") { "City" }
Input(id: "fieldset_city", placeholder: "New York")
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Horizontal with description", context: self) do
<<~RUBY
FormField(orientation: :horizontal, class: "w-2/3") do
Checkbox(id: "horizontal_newsletter")
FormFieldContent do
FormFieldLabel(for: "horizontal_newsletter") { "Subscribe to the newsletter" }
FormFieldHint { "One email a month, no spam." }
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Responsive", context: self) do
<<~RUBY
FormFieldGroup do
FormField(orientation: :responsive) do
FormFieldContent do
FormFieldLabel(for: "responsive_name") { "Name" }
FormFieldHint { "Side by side on wide containers, stacked on narrow ones." }
end
Input(id: "responsive_name", placeholder: "Joel Drapper")
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Choice card", context: self) do
<<~RUBY
FormFieldGroup(class: "w-2/3") do
FormFieldLabel(for: "plan_starter") do
FormField(orientation: :horizontal) do
FormFieldContent do
FormFieldTitle { "Starter" }
FormFieldHint { "For small teams getting started." }
end
RadioButton(id: "plan_starter", name: "plan", value: "starter", checked: true)
end
end
FormFieldLabel(for: "plan_pro") do
FormField(orientation: :horizontal) do
FormFieldContent do
FormFieldTitle { "Pro" }
FormFieldHint { "For growing teams that need more." }
end
RadioButton(id: "plan_pro", name: "plan", value: "pro")
end
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Separator", context: self) do
<<~RUBY
FormFieldGroup(class: "w-2/3") do
FormField do
FormFieldLabel(for: "separator_email") { "Email" }
Input(id: "separator_email", type: "email", placeholder: "[email protected]")
end
FormFieldSeparator { "Or" }
FormField do
FormFieldLabel(for: "separator_phone") { "Phone" }
Input(id: "separator_phone", type: "tel", placeholder: "+1 555 0100")
end
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Select", context: self) do
<<~RUBY
Form(class: "w-2/3 space-y-6") do
FormField do
FormFieldLabel(for: "form-select-fruit") { "Select" }
Select do
SelectInput(name: "fruit", required: true)
SelectTrigger(id: "form-select-fruit", class: "w-56") do
SelectValue(placeholder: "Select a fruit")
end
SelectContent do
SelectGroup do
SelectLabel { "Fruits" }
SelectItem(value: "apple") { "Apple" }
SelectItem(value: "orange") { "Orange" }
SelectItem(value: "banana") { "Banana" }
SelectItem(value: "watermelon") { "Watermelon" }
end
end
end
FormFieldError()
end
Button(type: "submit") { "Save" }
end
RUBY
end
render Docs::VisualCodeExample.new(title: "Combobox", context: self) do
<<~RUBY
Form(class: "w-2/3 space-y-6") do
FormField do
FormFieldLabel(for: "food-combobox") { "Combobox" }
Combobox(name: "food", required: true) do
ComboboxTrigger(id: "food-combobox") { ComboboxValue(placeholder: "Pick value") }
ComboboxContent do
ComboboxInput(placeholder: "Search", show_trigger: false)
ComboboxEmpty { "No result" }
ComboboxList do
ComboboxGroup do
ComboboxLabel { "Fruits" }
ComboboxItem(value: "apple") { "Apple" }
ComboboxItem(value: "banana") { "Banana" }
end
ComboboxSeparator()
ComboboxGroup do
ComboboxLabel { "Vegetable" }
ComboboxItem(value: "brocoli") { "Broccoli" }
ComboboxItem(value: "carrot") { "Carrot" }
end
ComboboxSeparator()
ComboboxGroup do
ComboboxLabel { "Others" }
ComboboxItem(value: "chocolate") { "Chocolate" }
ComboboxItem(value: "milk") { "Milk" }
end
end
end
end
FormFieldError()
end
Button(type: "submit") { "Save" }
end
RUBY
end
Heading(level: 2) { "Rails Integration" }
Text do
plain "RubyUI Form components are plain HTML — they work with any form submission strategy. "
plain "The recommended approach for Rails apps is to use "
InlineLink(href: "https://api.rubyonrails.org/classes/ActionView/Helpers/FormHelper.html#method-i-form_with", target: "_blank") { "form_with" }
plain " to generate the "
code(class: "font-mono text-sm") { "action" }
plain " URL and CSRF token, then pass explicit "
code(class: "font-mono text-sm") { "name" }
plain " / "
code(class: "font-mono text-sm") { "id" }
plain " attributes to each RubyUI input so the browser serialises them correctly. "
plain "Server-side errors can be surfaced by rendering "
code(class: "font-mono text-sm") { "FormFieldError" }
plain " with content from "
code(class: "font-mono text-sm") { "model.errors.full_messages_for(:attr)" }
plain "."
end
Heading(level: 3) { "Minimal Rails form" }
Codeblock(<<~RUBY, syntax: :ruby)
# In your Phlex view, call form_with via helpers:
# form_with(url: users_path, method: :post) passes action + CSRF automatically.
#
# You can also set action and the CSRF token manually:
Form(action: helpers.users_path, method: "post", class: "w-2/3 space-y-6") do
input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
FormField do
FormFieldLabel(for: "user_email") { "Email" }
Input(
type: "email",
id: "user_email",
name: "user[email]",
placeholder: "[email protected]",
required: true
)
FormFieldError()
end
Button(type: "submit") { "Continue" }
end
RUBY
Heading(level: 3) { "Devise-style login form" }
Codeblock(<<~RUBY, syntax: :ruby)
# Full sign-in form mirroring Devise session[email] / session[password] params.
# Pass backend errors (e.g. "Invalid email or password") into FormFieldError.
Form(action: helpers.user_session_path, method: "post", class: "space-y-6") do
input(type: "hidden", name: "authenticity_token", value: helpers.form_authenticity_token)
FormField do
FormFieldLabel(for: "session_email") { "Email" }
Input(
type: "email",
id: "session_email",
name: "session[email]",
placeholder: "[email protected]",
autocomplete: "email",
required: true
)
FormFieldError { @error_message }
end
FormField do
FormFieldLabel(for: "session_password") { "Password" }
Input(
type: "password",
id: "session_password",
name: "session[password]",
autocomplete: "current-password",
required: true,
minlength: "8"
)
FormFieldError()
end
FormField(orientation: :horizontal) do
Checkbox(id: "session_remember_me", name: "session[remember_me]", value: "1")
FormFieldLabel(for: "session_remember_me") { "Remember me" }
end
Button(type: "submit", class: "w-full") { "Sign in" }
end
RUBY
render Components::ComponentSetup::Tabs.new(component_name: component)
render Docs::ComponentsTable.new(component_files(component))
end
end
end
3
Add RubyUI::FormField to app/components/ruby_ui/form/form_field.rb
# frozen_string_literal: true
module RubyUI
class FormField < Base
# shadcn targets Base UI's role=checkbox/radio buttons; RubyUI's are native inputs.
ORIENTATIONS = {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal: [
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto",
"has-[>[data-slot=field-content]]:[&>input[type=checkbox],&>input[type=radio]]:mt-px"
].join(" "),
responsive: [
"flex-col *:w-full [&>.sr-only]:w-auto",
"@md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:[&>input[type=checkbox],&>input[type=radio]]:mt-px"
].join(" ")
}.freeze
def initialize(orientation: :vertical, **user_attrs)
@orientation = orientation&.to_sym
raise ArgumentError, "orientation must be one of #{ORIENTATIONS.keys.join(", ")}" unless ORIENTATIONS.key?(@orientation)
super(**user_attrs)
end
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
role: "group",
data: {
slot: "field",
controller: "ruby-ui--form-field",
orientation: @orientation
},
class: [
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
ORIENTATIONS[@orientation]
]
}
end
end
end
4
Add RubyUI::FormFieldContent to app/components/ruby_ui/form/form_field_content.rb
# frozen_string_literal: true
module RubyUI
class FormFieldContent < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {slot: "field-content"},
class: "group/field-content flex flex-1 flex-col gap-1 leading-snug"
}
end
end
end
5
Add RubyUI::FormFieldError to app/components/ruby_ui/form/form_field_error.rb
# frozen_string_literal: true
module RubyUI
class FormFieldError < Base
# errors: strings or objects responding to #message (ActiveModel::Error),
# e.g. user.errors[:email] or user.errors.where(:email). A block wins over
# errors. The div is rendered even when empty: the FormField controller
# writes client-side errors into it, and `empty:hidden` hides it meanwhile.
def initialize(errors: nil, **user_attrs)
@messages = Array(errors).map { |error| error.respond_to?(:message) ? error.message : error.to_s }
.reject { |message| message.strip.empty? }
.uniq
super(**user_attrs)
end
def view_template(&block)
div(**attrs) do
if block
yield
elsif @messages.one?
plain @messages.first
elsif @messages.any?
ul(class: "ml-4 flex list-disc flex-col gap-1") do
@messages.each { |message| li { message } }
end
end
end
end
private
def default_attrs
{
role: "alert",
data: {
slot: "field-error",
ruby_ui__form_field_target: "error"
},
class: "empty:hidden text-sm font-normal text-destructive"
}
end
end
end
6
Add RubyUI::FormFieldGroup to app/components/ruby_ui/form/form_field_group.rb
# frozen_string_literal: true
module RubyUI
class FormFieldGroup < Base
def view_template(&)
div(**attrs, &)
end
private
def default_attrs
{
data: {slot: "field-group"},
class: [
"group/field-group @container/field-group flex w-full flex-col gap-7",
"data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4"
]
}
end
end
end
7
Add RubyUI::FormFieldHint to app/components/ruby_ui/form/form_field_hint.rb
# frozen_string_literal: true
module RubyUI
class FormFieldHint < Base
def view_template(&)
p(**attrs, &)
end
private
def default_attrs
{
data: {
slot: "field-description",
ruby_ui__form_field_target: "hint"
},
class: [
"empty:hidden text-left text-sm leading-normal font-normal text-muted-foreground",
# shadcn's last:mt-0 nth-last-2:-mt-1, counting siblings as if an
# empty FormFieldError (always rendered here, never in shadcn) were absent.
# Right after a legend they step aside, so the legend rule wins as in shadcn.
"[&:nth-last-child(1_of_:not([data-slot=field-error]:empty)):not([data-variant=legend]+*)]:mt-0",
"[&:nth-last-child(2_of_:not([data-slot=field-error]:empty)):not([data-variant=legend]+*)]:-mt-1",
"[[data-variant=legend]+&]:-mt-1.5",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary"
]
}
end
end
end
8
Add RubyUI::FormFieldLabel to app/components/ruby_ui/form/form_field_label.rb
# frozen_string_literal: true
module RubyUI
class FormFieldLabel < Base
def view_template(&)
label(**attrs, &)
end
private
def default_attrs
{
data: {slot: "field-label"},
class: [
"empty:hidden flex w-fit items-center gap-2 text-sm leading-snug font-medium select-none",
"group-data-[disabled=true]/field:opacity-50",
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
"peer-aria-disabled:cursor-not-allowed peer-aria-disabled:opacity-50 peer-aria-disabled:pointer-events-none",
# A label wrapping a FormField becomes a selectable card (shadcn's
# has-data-checked maps to native :checked).
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border *:data-[slot=field]:p-3",
"has-[>[data-slot=field]]:not-has-[:disabled,[data-disabled]]:hover:bg-muted/50",
"has-[>[data-slot=field]]:has-[:focus-visible]:border-ring has-[>[data-slot=field]]:has-[:focus-visible]:ring-3 has-[>[data-slot=field]]:has-[:focus-visible]:ring-ring/50",
"has-checked:border-primary/30 has-checked:bg-primary/5 dark:has-checked:border-primary/20 dark:has-checked:bg-primary/10"
]
}
end
end
end
9
Add RubyUI::FormFieldLegend to app/components/ruby_ui/form/form_field_legend.rb
# frozen_string_literal: true
module RubyUI
class FormFieldLegend < Base
VARIANTS = %i[legend label].freeze
def initialize(variant: :legend, **user_attrs)
@variant = variant&.to_sym
raise ArgumentError, "variant must be one of #{VARIANTS.join(", ")}" unless VARIANTS.include?(@variant)
super(**user_attrs)
end
def view_template(&)
legend(**attrs, &)
end
private
def default_attrs
{
data: {slot: "field-legend", variant: @variant},
class: "mb-3 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base"
}
end
end
end
10
Add RubyUI::FormFieldSeparator to app/components/ruby_ui/form/form_field_separator.rb
# frozen_string_literal: true
module RubyUI
class FormFieldSeparator < Base
def view_template(&block)
div(**mix(attrs, data: {content: block ? "true" : "false"})) do
render RubyUI::Separator.new(decorative: false, class: "absolute inset-0 top-1/2")
if block
span(
data: {slot: "field-separator-content"},
class: "relative mx-auto block w-fit bg-background px-2 text-muted-foreground",
&block
)
end
end
end
private
def default_attrs
{
data: {slot: "field-separator"},
class: "relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2"
}
end
end
end
11
Add RubyUI::FormFieldSet to app/components/ruby_ui/form/form_field_set.rb
# frozen_string_literal: true
module RubyUI
class FormFieldSet < Base
def view_template(&)
fieldset(**attrs, &)
end
private
def default_attrs
{
data: {slot: "field-set"},
class: "flex flex-col gap-6 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3"
}
end
end
end
12
Add RubyUI::FormFieldTitle to app/components/ruby_ui/form/form_field_title.rb
# frozen_string_literal: true
module RubyUI
class FormFieldTitle < Base
def view_template(&)
div(**attrs, &)
end
private
# Shares the field-label slot with FormFieldLabel, as in shadcn, so a
# horizontal FormField stretches it the same way.
def default_attrs
{
data: {slot: "field-label"},
class: "flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50"
}
end
end
end
13
Add form_controller.js to app/javascript/controllers/ruby_ui/form_controller.js
import { Controller } from "@hotwired/stimulus";
// FormField cancels each `invalid` event to show its own error, which also
// cancels the browser's focus on the first invalid control. The form puts it
// back: the browser fires `invalid` for every failing control in tree order,
// all within one task, so the first event of that task is the first invalid
// control. The lock is released on the next task, not a microtask: on a real
// click the microtask queue drains between listener calls.
//
// form.reportValidity() focuses the first invalid control natively, so this
// matches it. form.checkValidity() also fires `invalid`, so calling it from
// app code moves focus too (and FormField shows the errors, as before).
export default class extends Controller {
#focusLocked = false;
#unlockTimer = null;
disconnect() {
clearTimeout(this.#unlockTimer);
this.#focusLocked = false;
}
focusFirstInvalid(event) {
if (this.#focusLocked) return;
// A control that can't take focus (hidden, in a collapsed panel) leaves
// the lock open, so the next invalid control gets it instead.
event.target.focus();
if (document.activeElement !== event.target) return;
this.#focusLocked = true;
this.#unlockTimer = setTimeout(() => {
this.#focusLocked = false;
});
}
}
14
Add form_field_controller.js to app/javascript/controllers/ruby_ui/form_field_controller.js
import { Controller } from "@hotwired/stimulus";
let fieldCount = 0;
export default class extends Controller {
static targets = ["input", "error", "hint"];
static values = { shouldValidate: false };
connect() {
this.#describeInputs();
// FormFieldError hides itself through `empty:hidden`, so its text is the
// only state: whitespace-only content is cleared to keep `:empty` true.
if (this.hasErrorTarget) {
if (this.errorTarget.textContent.trim()) {
this.shouldValidateValue = true;
this.#setInvalid(this.inputTargets);
} else {
this.errorTarget.textContent = "";
}
}
}
onInvalid(event) {
// Without a FormFieldError, the browser's own bubble shows the message.
if (this.hasErrorTarget) event.preventDefault();
this.shouldValidateValue = true;
this.#setErrorMessage();
}
onInput() {
this.#setErrorMessage();
}
onChange() {
this.#setErrorMessage();
}
#setErrorMessage() {
if (!this.shouldValidateValue) return;
// A field with several controls (e.g. required checkboxes) is invalid
// when any of them is; the message comes from the first invalid one.
const invalidInputs = this.inputTargets.filter((input) => !input.validity.valid);
if (this.hasErrorTarget) {
this.errorTarget.textContent = invalidInputs.length
? this.#getValidationMessage(invalidInputs[0])
: "";
}
this.#setInvalid(invalidInputs);
}
// Mirrors shadcn's Field pattern: data-invalid on the field (label turns
// destructive) and aria-invalid on the invalid controls.
#setInvalid(invalidInputs) {
if (invalidInputs.length) {
this.element.dataset.invalid = "true";
} else {
delete this.element.dataset.invalid;
}
this.inputTargets.forEach((input) => {
if (invalidInputs.includes(input)) {
input.setAttribute("aria-invalid", "true");
} else {
input.removeAttribute("aria-invalid");
}
});
}
// Points every control at the hint and error, keeping ids the author set.
#describeInputs() {
const ids = [
...this.hintTargets.map((hint) => [hint, "hint"]),
...(this.hasErrorTarget ? [[this.errorTarget, "error"]] : []),
].map(([element, part]) => this.#ensureId(element, part));
if (ids.length === 0) return;
this.inputTargets.forEach((input) => {
const current = (input.getAttribute("aria-describedby") || "")
.split(/\s+/)
.filter(Boolean);
const merged = [...new Set([...current, ...ids])];
input.setAttribute("aria-describedby", merged.join(" "));
});
}
#ensureId(element, part) {
if (!element.id) {
fieldCount += 1;
element.id = `ruby-ui-form-field-${fieldCount}-${part}`;
}
return element.id;
}
#getValidationMessage(input) {
let errorMessage;
const { validity, dataset, validationMessage } = input;
if (validity.tooLong) errorMessage = dataset.tooLong;
if (validity.tooShort) errorMessage = dataset.tooShort;
if (validity.badInput) errorMessage = dataset.badInput;
if (validity.typeMismatch) errorMessage = dataset.typeMismatch;
if (validity.stepMismatch) errorMessage = dataset.stepMismatch;
if (validity.valueMissing) errorMessage = dataset.valueMissing;
if (validity.rangeOverflow) errorMessage = dataset.rangeOverflow;
if (validity.rangeUnderflow) errorMessage = dataset.rangeUnderflow;
if (validity.patternMismatch) errorMessage = dataset.patternMismatch;
return errorMessage || validationMessage;
}
}
15
Update the Stimulus controllers manifest file
rake stimulus:manifest:update16
Install required components
Component Form relies on the following RubyUI components. Refer to their individual installation guides for setup instructions:
Components
| Component | Built using | Source |
|---|---|---|
Form | Phlex | |
FormDocs | Phlex | |
FormField | Phlex | |
FormFieldContent | Phlex | |
FormFieldError | Phlex | |
FormFieldGroup | Phlex | |
FormFieldHint | Phlex | |
FormFieldLabel | Phlex | |
FormFieldLegend | Phlex | |
FormFieldSeparator | Phlex | |
FormFieldSet | Phlex | |
FormFieldTitle | Phlex | |
FormController | Stimulus JS | |
FormFieldController | Stimulus JS |