Why use a Tailwind form builder?
Tailwind is already the first choice CSS framework for many Rails developers, it's incredibly easy to add to a project with the the --css=tailwind
flag of the rails new
command.
However, as you start to build out your app you'll find that you're repeating class names a lot. If you then want to change a shared style you'll have to go through and update all of those class names across the entire app.
Tailwind recommends you use partials and components for this but Rails doesn't have a native component solution and partials are a bit high-ceremony.
Often in small to medium sized apps the bulk of the app is managable directly in the views, but forms become a problem much more quickly.
Gems like Simple Form do a decent job of solving this but require significant configuration and are not always flexible enough for more complex forms.
All Rails forms use a form builder (by default ActionView::Helpers::FormBuilder
) which we can replace with our own, either at the app level or on a per-form basis. This allows us to override all the rails form methods like text_field
and select
and add our own classes and markup.
This gives us nice clean ERB form templates while giving us full control of the markup and classes, plus we keep our app dependency free.
What you'll build
Our tailwind form builder will be a simple class that inherits from ActionView::Helpers::FormBuilder
and provides the following functionality:
- Adds a layout wrapper around all fields which can be disabled both at the form level and the field level, which includes label and error messages
- Default colors for primary, secondary, gray and error, which can be overridden at the form level
- Default classes for spacing which can be overridden at the form level
- Ability to revert to the native rails form builder for a single field
- Ability to add custom classes to a field, these will override existing tailwind classes that set the same attribute, i.e.
block
will overrideinline-block
- Override all rails form methods including text type helpers, check_box, radio_button, select, file, button, submit, and label
- Provide an easy way to manage classes for all the various form elements
- Provide error messages helper
App setup
We start with the pattern_base1 base app which can also be created with the following commands:
APP_NAME=tailwind-form-builder-app
rails new $APP_NAME -d postgresql -c tailwind --skip-jbuilder --skip-test
cd $APP_NAME
git add -A && git commit -m "rails new $APP_NAME -d postgresql --skip-jbuilder --skip-test"
Let's create a simple Post scaffold to work with, and install Active Storage so we can add a file field to our form:
rails g scaffold post title:string body:text published:boolean
bin/rails active_storage:install
rails db:migrate
Let's add a few validations and the file field to the Post model so we can test error states and file uploads:
# app/models/post.rb
class Post < ApplicationRecord
validates :title, presence: true
validates :body, presence: true
validates :published, inclusion: { in: [true] }
has_one_attached :file
end
You may also want to skip the scaffold and use a model from your own app, or create a new model with a few fields to work with.
Now we're going to set our form builder as the default form builder app-wide. If you want to switch to the native builder for any specific form you can pass builder: ActionView::Helpers::FormBuilder
. Alternatively you could do the reverse: skip the app-wide configuration and set our app builder on a per-form basis by passing builder: FormBuilders::TailwindFormBuilder
to the form_with
helper.
The other thing we want to do is replace the default field error proc so that we can remove the extra markup Rails adds by default as we add our own error markup. Unfortunately this can only be set at an app level so this may cause problems if you intend to use both builders alongside each other and want to keep the default error markup in the original builder.
See the Rails docs for more information on both of these config options.
# config/initalizers/forms.rb
Rails.application.config.action_view.default_form_builder = "FormBuilders::TailwindFormBuilder"
Rails.application.config.action_view.field_error_proc = Proc.new { |html_tag, instance| html_tag }
Creating the form builder
First we're going to install the tailwind-merge gem which allows to concatenate strings of tailwind classes together with the later classes replacing the earlier ones if they conflict. There's a brief explanation of how that works on the gem page or watch this video for more information.
bundle add tailwind-merge
It's probably worth taking a quick look at the Rails custom form builder guides, Rails FormBuilder API docs and the FormBuilder source code before we get started, but it's not strictly necessary.
Let's start with a minimal form builder, and build it up from there.
# app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
class_attribute :text_field_helpers, default: field_helpers - %i[label check_box radio_button fields_for fields hidden_field file_field]
text_field_helpers.each do |field_method|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{field_method}(method, options = {})
if options.delete(:native)
super
else
text_like_field(#{field_method.inspect}, method, options)
end
end
RUBY_EVAL
end
def text_field_classes
"w-full rounded-lg font-light text-md lg:text-sm disabled:bg-#{gray_color}-50 disabled:text-#{gray_color}-500"
end
def border_classes
"border-0 shadow-sm ring-1 ring-inset ring-#{gray_color}-300 focus:ring-2 focus:ring-inset focus:ring-#{primary_color}-600"
end
private
def text_like_field(field_method, object_method, options = {})
classes = tailwind_classes(text_field_classes, border_classes, options[:class])
send(field_method, object_method, options.merge(native: true, class: classes))
end
def tailwind_classes(*args)
TailwindMerge::Merger.new.merge([*args].join(' '))
end
def primary_color
self.options[:primary_color] || "indigo"
end
def secondary_color
self.options[:secondary_color] || "pink"
end
def gray_color
self.options[:gray_color] || "slate"
end
def error_color
self.options[:error_color] || "red"
end
end
end
Here we override all the text type helpers, i.e. text_field
, email_field
, password_field
, etc. with a bit of metaprogramming and add our own Tailwind merged classes to the class
option. We also add a native
option which allows us to revert to the native rails form builder for a single field.
Now we need to make some changes to our Tailwind config, firstly to add the form builder to the list of content files so that the Tailwind CSS is recompiled when we make changes, and because the tailwind compiler won't pick up dynamic classes like ring-#{gray_color}-300
we need to add them to the safelist with their variants. See the Tailwind docs for more information.
// config/tailwind.config.js
const defaultTheme = require("tailwindcss/defaultTheme");
module.exports = {
content: ["./app/lib/form_builders/**/*.rb"],
safelist: [
{
pattern: /\!?(bg|text|ring|border)-(indigo|slate|red)-(\d00|50|950)/,
variants: [
"hover",
"focus",
"active",
"disabled",
"focus-visible",
"file",
],
},
],
};
Next we'll add error states for form elements and an error message helper.
# app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
def error_label(object_method, options)
return if errors_for(object_method).blank?
error_message = @object.errors.full_messages_for(object_method).join(", ").html_safe
label(object_method, error_message, class: "text-red-500 text-sm")
end
def error_messages(options = {})
return if object.errors.none?
@template.tag.div id: "error_explanation", class: error_message_classes do
@template.concat @template.tag.h2 "#{@template.pluralize(object.errors.count, "error")} prohibited this #{object.class.name.downcase} from being saved:"
@template.concat(
@template.tag.ul do
object.errors.full_messages.each do |message|
@template.concat @template.tag.li(message)
end
end
)
end
end
def error_message_classes
"bg-red-50 text-red-500 px-3 py-2 text-sm font-light rounded-lg #{vertical_gap}"
end
def error_classes(field_method, object_method)
return "" if errors_for(object_method).empty?
case field_method
when :check_box, :radio_button then "border-#{error_color}-300 focus:ring-#{error_color}-600"
else "ring-#{error_color}-300 focus:ring-#{error_color}-600"
end
end
private
def text_like_field(field_method, object_method, options = {})
layout = options.key?(:layout) ? options.delete(:layout) : form_layout
classes = tailwind_classes(text_field_classes, border_classes, error_classes(field_method, object_method), options[:class])
layout_for_if(layout, field_method, object_method, options.reverse_merge({})) do
send(field_method, object_method, options.merge(native: true, class: classes))
end
end
def errors_for(object_method)
return if @object.blank? || object_method.blank?
@object.errors[object_method]
end
end
end
Now let's add a layout option to the form, defaulted to true, and add a conditional wrapper around all the fields. The form builder provides a @template
object which is the view context, so we can use that to render the label and error messages using the helpers we created in the previous step.
# app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
attr_reader :form_layout
def initialize(object_name, object, template, options)
@form_layout = options.key?(:layout) ? options.delete(:layout) : true
super
end
def label(method, text = nil, options = {}, &block)
return super if options.delete(:native)
classes = tailwind_classes(label_classes, options.delete(:class))
super(method, text, options.merge(class: classes), &block)
end
def label_classes
"block text-sm leading-6 text-#{gray_color}-700"
end
private
def layout_for_if(layout, field_method, object_method, options = {})
return yield unless layout
layout_for(field_method, object_method, options) do
yield
end
end
def layout_for(field_method, object_method, options = {})
label_options = options.delete(:label) || {}
label_text = label_options.delete(:text)
all_label_classes = tailwind_classes(label_classes, label_options.delete(:class))
label_options.merge!(class: all_label_classes)
if field_method.in? %i[check_box radio_button]
@template.tag.div(class: "#{vertical_gap}") do
@template.concat(
@template.tag.div(class: "flex flex-row items-center") do
@template.concat yield
@template.concat label(object_method, label_text, label_options)
end
)
@template.concat error_label(object_method, options)
end
else
@template.tag.div class: "flex flex-col #{vertical_gap}" do
@template.concat label(object_method, label_text, label_options)
@template.concat yield
@template.concat error_label(object_method, options)
end
end
end
end
end
Finally we'll add helpers for the other form elements.
# app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
def check_box(method, options = {}, checked_value = "1", unchecked_value = "0")
return super if options.delete(:native)
layout = options.key?(:layout) ? options.delete(:layout) : form_layout
classes = tailwind_classes(check_box_classes, error_classes(:check_box, method), options[:class])
layout_for_if(layout, :check_box, method, options.reverse_merge({})) do
super(method, options.merge(class: classes), checked_value, unchecked_value)
end
end
def radio_button(method, tag_value, options = {})
return super if options.delete(:native)
layout = options.key?(:layout) ? options.delete(:layout) : form_layout
classes = tailwind_classes(radio_button_classes, error_classes(:radio_button, method), options[:class])
layout_for_if(layout, :radio_button, method, options.reverse_merge({})) do
super(method, tag_value, options.merge(class: classes))
end
end
def file_field(method, options = {})
return super if options.delete(:native)
layout = options.key?(:layout) ? options.delete(:layout) : form_layout
classes = tailwind_classes(file_field_classes, error_classes(:file, method), border_classes, error_classes(:file, method), options[:class])
layout_for_if(layout, :file_field, method, options.reverse_merge({})) do
super(method, options.merge(class: classes))
end
end
def select(method, choices = nil, options = {}, html_options = {}, &block)
return super if options.delete(:native)
layout = options.key?(:layout) ? options.delete(:layout) : form_layout
classes = tailwind_classes(select_classes, error_classes(:select, method), border_classes, error_classes(:select, method), html_options.delete(:class))
layout_for_if(layout, :select, method, options.reverse_merge({})) do
super(method, choices, options, html_options.merge(class: classes), &block)
end
end
def label(method, text = nil, options = {}, &block)
return super if options.delete(:native)
classes = tailwind_classes(label_classes, options.delete(:class))
super(method, text, options.merge(class: classes), &block)
end
def submit(value = "Save changes", options = {})
return super if options.delete(:native)
classes = tailwind_classes(button_classes, options.delete(:class))
super(value, { data: { turbo_submits_with: "Submitting..." } }.deep_merge(options.merge(class: classes)))
end
def button(value = nil, options = {}, &block)
return super if options.delete(:native)
classes = tailwind_classes(button_classes, options.delete(:class))
super(value, options.merge(class: classes), &block)
end
def check_box_classes
"h-4 w-4 rounded border-#{gray_color}-300 text-#{primary_color}-600 focus:ring-#{primary_color}-600 #{horizontal_gap}"
end
def radio_button_classes
"h-4 w-4 border-#{gray_color}-300 text-indigo-600 focus:ring-indigo-600 #{horizontal_gap}"
end
def file_field_classes
tailwind_classes(
"block w-full text-sm text-#{gray_color}-900 rounded-lg cursor-pointer bg-#{gray_color}-50 focus:outline-0",
"file:mr-4 file:py-2 file:px-4 file:rounded-l-lg file:border-0 file:text-sm file:font-semibold file:bg-#{primary_color}-500 file:text-white hover:file:bg-#{primary_color}-400 hover:file:cursor-pointer"
)
end
def select_classes
"block w-full rounded-md py-1.5 pl-3 pr-10 text-#{gray_color}-900 sm:text-sm sm:leading-6"
end
def button_classes
"w-full justify-center block rounded-md bg-#{primary_color}-500 py-2 px-3 text-center text-sm font-semibold text-white shadow-sm hover:bg-#{primary_color}-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-#{primary_color}-500 cursor-pointer disabled:opacity-60 disabled:pointer-events-none #{vertical_gap}"
end
end
end
To give it a trial on our post model form let's change it like so:
<!-- app/views/posts/_form.html.erb -->
<%= form_with(model: post, class: "mt-3", primary_color: "rose") do |form| %>
<% # Error messages go at the top %>
<%= form.error_messages %>
<% # Here we show overriding the label text and adding a class to it %>
<%= form.text_field :title, placeholder: "Title", label: { text: "Something else", class: 'text-green-700' } %>
<%= form.text_area :body, rows: 4 %>
<%= form.check_box :published %>
<%= form.radio_button :published, true %>
<%= form.file_field :file %>
<%= form.select :title, ['Option 1', 'Option 2'], include_blank: true %>
<% # Test rendering a field without layout %>
<%= form.text_field :title, layout: false %>
<% # Test rendering a native rails field %>
<%= form.text_field :title, native: true %>
<%= form.submit %>
<%= form.button "Cancel" %>
<% end %>
And that's it, you can see the full class below in the diff.
Testing
Test framework setup
We use our standard test framework setup layer which includes RSpec, FactoryBot, Cuprite and Capybara, based on the Evil Martians system of a test.
In addition we're going to add the RSpec HTML Matchers Gem as it allows us to test for the presence of HTML elements and their attributes in a more readable way than the standard RSpec matchers.
bundle add rspec-html-matchers --group "test"
# spec/support/html_matchers.rb
RSpec.configure do |config|
config.include RSpecHtmlMatchers
end
Factories
We just want a basic post model factory to test this example
# spec/factories/posts.rb
FactoryBot.define do
factory :post do
title { "MyString" }
body { "MyText" }
published { false }
end
end
Helper test
We don't want to test every available option in the form builder, so we're aiming here for a rough test that covers the main functionality and gives us confidence that the form builder is working as expected. First we test the whole builder:
- Form renders with layout by default
- Form renders without layout when layout option is false
- Form renders with custom primary color (we assume others work if this works)
- Form renders with error messages
Then we test the individual form elements:
- Text field renders with layout by default (we assume others work if this works)
- Text field renders without layout when layout option is false
- Text field renders with native rails classes when native option is true
- Text field renders with custom classes and classes merge correctly
- Text field renders with custom label text and classes
- Check box works correctly (we assume radio work if this works)
- Select works correctly
- Submit button works correctly (we assume button works if this works)
As you can see the RSpec HTML matchers allow us to create DRY semantic tests that are easy to read and understand.
# spec/helpers/tailwind_form_builder_spec.rb
require 'rails_helper'
RSpec.describe FormBuilders::TailwindFormBuilder, type: :helper do
let(:object) { build(:post) }
let(:builder) { described_class.new(:post, object, self, {}) }
describe 'form' do
context 'by default' do
subject do
form_with(model: object, builder: FormBuilders::TailwindFormBuilder) do |form|
concat form.text_field(:title)
concat form.submit
end
end
it 'returns a styled form with layout by default' do
expect(subject).to have_tag('form') do
with_tag('div', with: { class: 'flex' }) do
with_tag('input:last-child', with: { type: 'text', class: 'ring-1' })
end
with_tag('input', with: { type: 'submit', value: 'Save changes', class: 'w-full', 'data-turbo-submits-with': 'Submitting...' })
end
end
end
context 'when the layout option is false' do
subject do
form_with(model: object, builder: FormBuilders::TailwindFormBuilder, layout: false) do |form|
concat form.text_field(:title)
concat form.submit
end
end
it 'returns a styled form without layout wrapper' do
expect(subject).not_to have_tag('div')
expect(subject).not_to have_tag('label')
expect(subject).to have_tag('input', with: { type: 'text', class: 'ring-1' })
end
end
context 'when the primary color is set to green' do
subject do
form_with(model: object, builder: FormBuilders::TailwindFormBuilder, primary_color: 'green') do |form|
concat form.text_field(:title)
concat form.submit
end
end
it 'returns a styled form with green primary color' do
expect(subject).to have_tag('form') do
with_tag('input', with: { class: 'focus\:ring-green-600' })
with_tag('input', with: { type: 'submit', class: 'bg-green-500 hover\:bg-green-600' })
end
end
end
context "when the form object has errors" do
subject do
object.errors.add(:title, "can't be blank")
form_with(model: object, builder: FormBuilders::TailwindFormBuilder) do |form|
concat form.error_messages
concat form.text_field(:title)
concat form.submit
end
end
it "returns a styled form with error messages" do
expect(subject).to have_tag('form') do
expect(subject).to have_tag('div', with: { class: 'bg-red-50' }) do
with_tag('h2') { with_text '1 error prohibited this post from being saved:' }
with_tag('li') { with_text "Title can't be blank" }
end
end
end
end
end
describe 'text field' do
context 'by default' do
subject { builder.text_field(:title) }
it 'returns a styled text field with layout by default' do
expect(subject).to have_tag('div', with: { class: 'flex' }) do
expect(subject).to have_tag('label:first-child') { with_text 'Title' }
expect(subject).to have_tag('input:last-child', with: { type: 'text', class: 'ring-1' })
end
end
end
context 'when the layout option is false' do
subject { builder.text_field(:title, layout: false) }
it 'returns a styled text field without label and layout wrapper' do
expect(subject).not_to have_tag('div')
expect(subject).not_to have_tag('label')
expect(subject).to have_tag('input', with: { type: 'text', class: 'ring-1' })
end
end
context 'when the native option is true' do
subject { builder.text_field(:title, native: true) }
it 'returns the native text field without label and layout wrapper' do
expect(subject).not_to have_tag('div')
expect(subject).not_to have_tag('label')
expect(subject).to have_tag('input', with: { type: 'text', class: nil })
end
end
context 'with custom classes' do
subject { builder.text_field(:title, layout: false, class: 'custom-class') }
it 'returns a styled input with custom classes' do
expect(subject).to have_tag('input', with: { class: 'ring-1 custom-class' })
end
end
context 'with custom tailwind class overriding existing tailwind class' do
subject { builder.text_field(:title, layout: false, class: 'w-auto') }
it 'returns a styled input with the new class and not the old one' do
expect(subject).to have_tag('input', with: { class: 'w-auto' })
expect(subject).not_to have_tag('input', with: { class: 'w-full' })
end
end
context 'with custom label text and classes' do
subject { builder.text_field(:title, label: { text: 'Custom label', class: 'text-green-700' }) }
it 'returns a styled text field with custom label text and classes' do
expect(subject).to have_tag('label', with: { class: 'text-green-700' }) { with_text 'Custom label' }
expect(subject).not_to have_tag('label', with: { type: 'text', class: 'text-slate-700' })
end
end
end
describe 'check box' do
context 'by default' do
subject { builder.check_box(:published) }
it 'returns a styled check box with layout by default' do
expect(subject).to have_tag('div', with: { class: 'flex' }) do
expect(subject).to have_tag('input:nth-last-child(2)', with: { type: 'checkbox', class: 'rounded' }) # Checkboxes also have the hidden field
expect(subject).to have_tag('label:last-child') { with_text 'Published' }
end
end
end
context 'when the layout option is false' do
subject { builder.check_box(:published, layout: false) }
it 'returns a styled check box without label and layout wrapper' do
expect(subject).not_to have_tag('div')
expect(subject).not_to have_tag('label')
expect(subject).to have_tag('input', with: { type: 'checkbox', class: 'rounded' })
end
end
context 'when the native option is true' do
subject { builder.check_box(:published, native: true) }
it 'returns the native check box without label and layout wrapper' do
expect(subject).not_to have_tag('div')
expect(subject).not_to have_tag('label')
expect(subject).to have_tag('input', with: { type: 'checkbox', class: nil })
end
end
end
describe 'select' do
context 'by default' do
subject { builder.select(:published, %w[Yes No]) }
it 'returns a styled select with layout by default' do
expect(subject).to have_tag('div', with: { class: 'flex' }) do
expect(subject).to have_tag('label:first-child') { with_text 'Published' }
expect(subject).to have_tag('select:last-child', with: { class: 'ring-1' }) do
expect(subject).to have_tag('option', with: { value: 'Yes' }, text: 'Yes')
expect(subject).to have_tag('option', with: { value: 'No' }, text: 'No')
end
end
end
end
end
describe "submit button" do
context 'by default' do
subject { builder.submit }
it "returns a styled submit button" do
expect(subject).to have_tag("input", with: { type: "submit", value: "Save changes", class: "w-full", "data-turbo-submits-with": "Submitting..." })
end
end
end
end
Todo
- In a future update we would like to remove the dependency on the Post model and test the form builder in isolation.