Building forms
Pass the form builder and the attribute, and each field takes its name, value, label and errors from your model. Validation works the Rails way: save, and on failure render the form again with a 422.
Bind a field to a model
Give a field form: and field: and it renders through the builder, so it gets a scoped name like user[email] and the model's current value.
<%= form_with model: @user do |f| %>
<%= render(UI::Input.new(form: f, field: :email, type: :email, hint: "We'll only use it for receipts.")) %>
<%= render(UI::Textarea.new(form: f, field: :bio)) %>
<%= render(UI::Select.new(form: f, field: :plan, options: Plan::NAMES)) %>
<% end %>
-
The label is the attribute's human name, so
activerecord.attributestranslations apply. Passlabel:to change it, orlabel: falseto leave it out. -
A hint (
hint:) sits under the field, and screen readers read it with the field. - Input, Textarea, Select, Native select, Checkbox, Password input and Date picker all work this way. Form field adds the same label, hint and error to a control of your own.
Showing errors
A bound field shows the model's errors on its attribute: a red border, the message underneath, and aria-invalid with the message as its description. For a team_id field that includes belongs_to :team's "Team must exist". Nothing extra to write.
<%# From the model, automatically: %>
<%= render(UI::Input.new(form: f, field: :email)) %>
<%# Your own message, or none: %>
<%= render(UI::Input.new(form: f, field: :email, error: "Use your work email")) %>
<%= render(UI::Input.new(form: f, field: :email, error: false)) %>
Rails wraps every field that has an error in <div class="field_with_errors">, which can break a form's layout. The fields show errors themselves, so turn the wrapper off:
# The fields show their own errors, so Rails needn't wrap each
# one in <div class="field_with_errors">.
config.action_view.field_error_proc = ->(html, _instance) { html }
Example: sign in
Submit it empty to see field errors, then try a wrong password for an error on the whole form. The demo account is ada@example.com with velvetkit.
<%= form_with model: sign_in, url: form_demo_sign_in_path, class: "flex flex-col gap-5" do |f| %>
<%# Errors on the form as a whole, rather than on one field. %>
<% if sign_in.errors[:base].any? %>
<%= render(UI::Alert.new(variant: :destructive)) do |alert| %>
<% alert.with_title { sign_in.errors[:base].to_sentence } %>
<% end %>
<% end %>
<%= render(UI::Input.new(form: f, field: :email, type: :email, autocomplete: "email")) %>
<%= render(UI::PasswordInput.new(form: f, field: :password, autocomplete: "current-password")) %>
<%= render(UI::Checkbox.new(form: f, field: :remember_me, label: "Keep me signed in")) %>
<%= render(UI::Button.new(type: :submit, full_width: true)) { "Sign in" } %>
<% end %>
class SignIn
include ActiveModel::Model
include ActiveModel::Attributes
attribute :email, :string
attribute :password, :string
attribute :remember_me, :boolean, default: false
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP, allow_blank: true }
validates :password, presence: true
# The user, or nil with an error on the form as a whole: which of the two
# was wrong is not something to tell a stranger.
def authenticate
return unless valid?
User.authenticate_by(email:, password:).tap do |user|
errors.add(:base, "Email or password is incorrect") unless user
end
end
end
class SessionsController < ApplicationController
def new
@sign_in = SignIn.new
end
def create
@sign_in = SignIn.new(params.expect(sign_in: [ :email, :password, :remember_me ]))
if (user = @sign_in.authenticate)
start_new_session_for user
redirect_to after_authentication_url
else
# 422 tells Turbo to render the form again, errors and all.
render :new, status: :unprocessable_entity
end
end
end
Example: invite a teammate
A longer form with every kind of field. Each one has a validation that can fail: send it empty, pick a start date in the past, or leave the permission box unticked. The errors are listed at the top as well as under each field, and the field names come from the locale file, so a label and its error always match.
<%= form_with model: invitation, url: form_demo_invitation_path, class: "grid gap-5 sm:grid-cols-2" do |f| %>
<%# One summary at the top, so a long form says what's wrong before the reader scrolls. %>
<% if invitation.errors.any? %>
<%= render(UI::Alert.new(variant: :destructive, class: "sm:col-span-2")) do |alert| %>
<% alert.with_title { "#{pluralize(invitation.errors.count, "problem")} to fix before sending" } %>
<% alert.with_description do %>
<ul class="list-disc pl-4"><% invitation.errors.full_messages.each do |message| %><li><%= message %></li><% end %></ul>
<% end %>
<% end %>
<% end %>
<%= render(UI::Input.new(form: f, field: :name, autocomplete: "off")) %>
<%= render(UI::Input.new(form: f, field: :email, type: :email, hint: "We'll send the invite here.")) %>
<%= render(UI::Select.new(form: f, field: :role, options: Demo::Invitation::ROLES, prompt: "Choose a role")) %>
<%= render(UI::NativeSelect.new(form: f, field: :team_id, options: Demo::Invitation::TEAMS, prompt: "Choose a team")) %>
<%= render(UI::DatePicker.new(form: f, field: :starts_on)) %>
<div class="hidden sm:block"></div>
<%= render(UI::Textarea.new(form: f, field: :note, class: "sm:col-span-2", rows: 3,
hint: "Up to 160 characters, added to the invite email.")) %>
<div class="flex flex-col gap-4 sm:col-span-2">
<%# A switch only submits when on, so send "0" first for when it's off. %>
<%= f.hidden_field :notify, value: "0", id: nil %>
<%= render(UI::Switch.new(name: "invitation[notify]", checked: invitation.notify, label: "Email me when they join")) %>
<%= render(UI::Checkbox.new(form: f, field: :terms, label: "I have permission to invite this person")) %>
</div>
<div class="sm:col-span-2">
<%= render(UI::Button.new(type: :submit)) { "Send invite" } %>
</div>
<% end %>
class Invitation < ApplicationRecord
ROLES = %w[Admin Member Viewer].freeze
belongs_to :team # "Team must exist" shows under the team_id field
validates :name, :email, :role, :starts_on, presence: true
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
validates :role, inclusion: { in: ROLES }, allow_blank: true
validates :note, length: { maximum: 160 }
validates :terms, acceptance: true # not a column: Rails adds the attribute
validate :starts_on_is_not_in_the_past
private
def starts_on_is_not_in_the_past
errors.add(:starts_on, "can't be in the past") if starts_on&.past?
end
end
en:
activerecord:
attributes:
invitation:
team_id: "Team"
starts_on: "Start date"
note: "Personal note"
terms: "Permission"
class InvitationsController < ApplicationController
def new
@invitation = Invitation.new
end
def create
@invitation = Invitation.new(invitation_params)
if @invitation.save
redirect_to team_path(@invitation.team), notice: "Invite sent to #{@invitation.email}"
else
render :new, status: :unprocessable_entity
end
end
private
def invitation_params
params.expect(invitation: [ :name, :email, :role, :team_id, :starts_on, :note, :notify, :terms ])
end
end
The view tabs show the exact templates running above. In the demo they post to this site and use a stand-in model with the same validations; the model and controller tabs show what they'd be in your app.