Form
Wrapper over Rails form_with with FormField, FormLabel, FormDescription, FormMessage.
Installation
bin/rails g wabi:add formForm wraps Rails' form_with and yields the form builder so you can keep using form.email_field, form.text_area, etc. with full ActiveModel / ActiveRecord integration.
Example
Try submitting with an invalid email vs a valid one (e.g. you@example.com). The demo controller intercepts submit so the docs page never reloads.
render Components::UI::Form.new(model: @user, url: "#") do |form|
render Components::UI::FormField.new do
render Components::UI::FormLabel.new(for_: "user_name") { "Name" }
form.text_field :name, id: "user_name", required: true,
class: "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
render Components::UI::FormDescription.new { "At least 2 characters." }
render Components::UI::FormMessage.new(model: @user, field: :name)
end
render Components::UI::FormField.new do
render Components::UI::FormLabel.new(for_: "user_email") { "Email" }
form.email_field :email, id: "user_email", required: true,
class: "h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
render Components::UI::FormDescription.new { "We'll never share your email." }
render Components::UI::FormMessage.new(model: @user, field: :email)
end
render Components::UI::FormField.new do
render Components::UI::FormLabel.new(for_: "user_bio") { "Bio" }
form.text_area :bio, id: "user_bio", rows: 3,
class: "w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
render Components::UI::FormDescription.new { "At least 10 characters. Markdown supported." }
render Components::UI::FormMessage.new(model: @user, field: :bio)
end
render Components::UI::FormField.new do
div(class: "flex items-center gap-2") do
form.check_box :newsletter, id: "user_newsletter",
class: "h-4 w-4 rounded border-input"
render Components::UI::FormLabel.new(for_: "user_newsletter") { "Subscribe to the newsletter" }
end
end
render Components::UI::Button.new(type: "submit") { "Save" }
end
Source
app/components/ui/form.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
# Wrapper over Rails' form_with. Yields the form builder so apps continue
# using Rails form helpers (form.email_field, form.text_area, etc.) for
# idiomatic ActiveModel/ActiveRecord integration.
#
# Requires a Rails view context — uses the Phlex::Rails::Helpers::FormWith
# adapter (the recommended pattern in phlex-rails 2.4+).
class Form < Wabi::Base
include Phlex::Rails::Helpers::FormWith if defined?(Phlex::Rails::Helpers::FormWith)
def initialize(model: nil, url: nil, scope: nil, **opts)
@model = model
@url = url
@scope = scope
@opts = opts
end
def view_template(&block)
user_class = @opts.delete(:class)
form_with(
model: @model, url: @url, scope: @scope, **@opts,
class: merge_class("space-y-6", user_class)
) do |form|
yield(form) if block_given?
end
end
end
end
end
app/components/ui/form_field.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class FormField < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
div(**@attrs, class: merge_class("space-y-2", user_class)) do
yield if block_given?
end
end
end
end
end
app/components/ui/form_label.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
class FormLabel < Wabi::Base
LABEL_CLASS = "text-sm font-medium leading-none " \
"peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
# `for_:` matches the standalone Components::UI::Label convention.
def initialize(for_: nil, **attrs)
@for_ = for_
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
label(
**@attrs,
for: @for_,
class: merge_class(LABEL_CLASS, user_class)
) do
yield if block_given?
end
end
end
end
end
app/components/ui/form_description.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
# Renders helper/hint text for a form field.
#
# Accessible-name convention (WCAG 1.3.1):
# Pass `id: "#{field_name}_description"` and wire the adjacent input with
# `aria-describedby: "#{field_name}_description"` so assistive technology
# announces the description when the input is focused.
#
# Example:
# render FormDescription.new(id: "email_description") { "We'll never share your email." }
# # input: aria_describedby: "email_description"
class FormDescription < Wabi::Base
def initialize(**attrs)
@attrs = attrs
end
def view_template(&block)
user_class = @attrs.delete(:class)
p(**@attrs, class: merge_class("text-sm text-muted-foreground", user_class)) do
yield if block_given?
end
end
end
end
end
app/components/ui/form_message.rb
# frozen_string_literal: true
require "date" # Phlex 2.4 references Date/Time constants lazily when rendering data:{} hashes
module Components
module UI
# Renders a field-level validation error message.
#
# Accessible-name convention (WCAG 1.3.1 / 4.1.3):
# Pass `id: "#{field_name}_error"` so the adjacent input can reference it via
# `aria-describedby: "#{field_name}_error"`. This allows assistive technology
# to announce the error when the input is focused.
#
# Example:
# render FormMessage.new(model: @user, field: :email, id: "email_error")
# # input: aria_describedby: "email_error", aria_invalid: true
#
# aria-invalid convention (WCAG 1.3.1):
# When FormMessage renders for a field (indicating invalidity), the corresponding
# input MUST carry `aria-invalid: true`. FormMessage itself emits role="alert" to
# announce the error via live region, but the input's aria-invalid state must be
# set by the caller since FormMessage has no direct reference to the input element.
class FormMessage < Wabi::Base
def initialize(model: nil, field: nil, text: nil, **attrs)
@model = model
@field = field
@text = text
@attrs = attrs
end
def view_template
msg = @text || (@model && @field && @model.errors[@field].first)
return unless msg
user_class = @attrs.delete(:class)
# role="alert" so screen readers announce the validation error when it
# appears; callers can override via attrs (e.g. role: "status").
p(role: "alert", **@attrs, class: merge_class("text-sm font-medium text-destructive", user_class)) { msg }
end
end
end
end
Accessibility
- FormLabel uses for= to associate with the input id.
- FormDescription is intended to be referenced via aria-describedby on the input (app wiring).
- FormMessage is intended to be referenced via aria-describedby on the input when an error is present.
- Form yields the standard Rails form builder so all native input helpers (email_field, text_area, etc.) keep working.