Recently I shared some of my struggles writing forms in standalone JavaScript UI frameworks. Forms are:
- an inevitable reality in web development
- cruft that we need to capture data
- things that allow us to actually provide value to users
Re-inventing the wheel on basic concerns like validation, storing and composing form data, and persisting it means we're losing time and energy we'd rather dedicate to more valuable features.It's sad when we give up the elegant tools Rails gives us to quickly build out forms.
Equally, I've found using modern styling solutions can feel cumbersome with Rails forms. Tailwind is great, especially a component-based UI. There is a clear translation from components to partials / views, but I have never found the right way to think about how we build forms.
In the past, I've been guilty of copy-pasting from other forms in the project and tweaking to satisfy anew requirement. That's the path of least resistance when you don't have a decent strategy in place or are unfamiliar with where the right place to make an abstraction. It also leads to bad design choices by breeding inconsistency across teams. Every form drifts ever so slightly away from the original, like a bad game of telephone.Finding the right way to compose those forms is tricky!
I don't think I'm alone there. All sorts of gems try to help smooth out that experience and make forms feel more 'component-y'. Simple form is a great example! If you've never tried those gems, they might appeal to you!
Lately, I've been trying to engage my curiosity about the tools I use before jumping to use a dependency.Dependencies aren't bad by any means! We just want to be very conscious of what tradeoff we're making by including them. Often it's worth the tradeoff, but by running straight to them, Limit my opportunity to go deeper in my understanding of the stack I use most often.
Queue a great idea from Justin Searls. He's shown me how to lean on Rails defaults for building forms in a way that I think hits a sweet spot between views and Tailwind styling. With a small bit of exploration of the Rails internals, we might be able to free our forms from leaning on another dependency.
The challenge of Tailwind-y Rails forms
Note: I'll be writing .erb style syntax without their requisite <%= %> && <% %>
tags, so they format more kindly in markdown. Please forgive me :`)
If you've also tried styling your Rails app with Tailwind, you might have made a form that looks like this:
# In our bakery show view, somewhere
form_with model: @bakery do |f|
f.label :bakery_name, "Bakery Name", class: "block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
f.text_field :name, class: "mt-1 block w-full rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
f.label :bakery_email, "Bakery Email", class: "block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
f.text_field :email, class: "mt-1 block w-full rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
f.label :bakery_country, "Bakery Country", class: "block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
f.text_field :country, class: "mt-1 block w-full rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
f.label :bakery_open, "Open?", class: "block text-gray-500 font-bold md:text-right mb-1 md:mb-0 pr-4"
f.check_box :open, disabled: true
end
The principles of Tailwind and ActionView can feel opposed to each other.
The form_with
helper gives us a reusable structure for creating forms. We directly interact with model attributes, with an easy DSL to build forms for an underlying object. Rails is opinionated about the behaviour, so we know what to do, with enough flexibility to customize the HTML output.
We get so many things for free. Validations against the model are tied to the matching field on the form. We can easily pass error messages to the view. The form knows where to submit itself and how to package up its form data. And all the other things.
Tailwind gives us a concise CSS DSL, blended with an inline first approach to styling that fits the componentizedworld of web development. Tailwind is heavily opinionated about applying style inline with your HTML, with a lot of benefits. No more abandoned CSS classes. Avoid abstractions that evolve, grow, bloat,and deteriorate. Mobile-first styling, easy theming, dark mode, and so on.
When we use these two tools together, we can wind up with a bit of a mess.The example code above is a case in point. Even a simple form with a few attributes has us copy-pasting the same styling to all of them. It feels like we're doing something wrong. The styling is so wet that we lose sight of the simple form syntax that makes us love Rails, but we need to keep our styling inline with our HTML.
How do we reconcile this?
We can start by leaning on app-wide theme styling, letting Tailwind take care of those concerns at our config layer.Utilizing a Tailwind theme for things like our brand colors is the way.
If we slip and consider using @apply
to add custom classes to our form elements, we break convention.
# no, bad. Don't be tricked!
form_with model: @bakery, class: 'bakery_form' do |f|
f.label :bakery_name, "Bakery Name", class: "form-label"
f.text_field :name, class: "form-text-input"
f.label :bakery_email, "Bakery Email", class: "form-label"
f.text_field :email, class: "form-text-input"
f.label :bakery_country, "Bakery Country", class: "form-label"
f.text_field :country, class: "form-text-input"
f.label :bakery_open, "Open?", class: "form-label"
f.check_box :open, disabled: true, class: "form-check-box"
end
We see this DRY style and feel better. It's a more comfortable read if we're used to traditional CSS approaches.
If we fall for this trap, reality will smack us as it always does.
What happens when we need to extend a single input style, one time, on one form? That one-off gets forgotten and regresses somewhere down the line.
What happens if bakery_form
styles get reused? Once it makes a single leap to another form, it'll make 3 more before you blink. Then we have a CSS class to maintain across multiple forms and contexts.
Changing global CSS is terrifying on large projects.
Inevitably, these abstractions push us away from what is awesome about Tailwind. There are a whole lot of reasons Tailwind tells us to avoid the@apply
abstraction. Fear not!
Tailwind also gives guidance on managing duplication of styling, wisdom akin to "tolerate some amount of repetition over premature abstraction".They coach us to dry out our inline styling in two ways; loop intelligently, and lean on partials.When possible, we should map over our collection of things to be rendered. If that doesn't fit, lean into partials to share styling across contexts.
Forms fall into a challenging middle ground where neither of these choices solves our problem without issues.
Looping through model attributes to generate an input collection creates interdependency between each input.It's also hard to reuse form partials. My Bakery model is very different from my Cake model, but I probably want similarity in the way the forms look.
There's a better way to lean into Rails forms with Tailwind in an expected and concise way; Form builders.
The Rails Gods give us a way to make Custom Form Builders, and @searls pointed me towards a creative tie-in to utilizing them effectively while styling forms across anapplication.
The gist
TL;DR:
We can make a small seam in the FormBuilder
Rails provides to us to apply styles, then jump right back to the default functionality.
I've got a sample app if you'd prefer I show you the code.
There is a principle I think pairs nicely with the form builder approach:
Wrap with structure, fill with style.
A slightly longer introduction
The sample repo's "finished product" has some clever optimizations. If you're comfortable with inheritance, meta programming, and a small bit of recursion, I think you'll be set to hop in and grok the principle.
If those things are new to you, or you'd like a refresher, no worries! I've got you! I'll detail the most simple implementation of this builder and incrementally work towards those clever optimizations step by step in the sections below.
Rails lets us define a custom Form Builder to change what happens when we call form_with
in a view.
We'll make a custom builder whose only job is to insert our styling.
Then the builder will point us straight back to Rails magic land, where we get validation, submission, data wrangling, and all the other things for free from the form_with
helper.
First, I want you to remember the primary guiding principle that's going to help us balance Tailwind and Rails best practices.
Wrap with structure, fill with style.We want to do layout styling in our view, wrapping our form_with
elements.The form builder should fill in our appearance styling.
The setup
There are four things we need to get started:
- Tailwind configuration, so it sees styles on the builder
- How to call your form builder
- Implementing the
FormBuilder
- Leveraging the builder alongside our form
The sample app and the examples below are based on an Adventurer
model with a simple edit view.
├── app
│ ├── helpers
│ │ └── application_helper.rb
│ ├── lib
│ │ └── form_builders
│ │ └── tailwind_form_builder.rb
│ └── views
│ └── adventurers
│ └── show.html.erb
└── tailwind.config.js
tailwind.config.js
First, we want to configure Tailwind to check for styling in the form builder.
Find your tailwind.config.js
, and include:
module.exports = {
content: [
'./app/views/**/*.html.erb', // you probably have all these ones!
'./app/helpers/**/*.rb',
'./app/assets/stylesheets/**/*.css',
'./app/javascript/**/*.js',
'./app/lib/form_builders/**/*.rb', // Add this one! Or, whatever directory you'd like to store your builder
]
}
Step one is done!
How to call the form builder
There are two primary ways Rails expects a custom builder to be invoked. The right place depends on what abstraction is appropriate for your project.
Every form_with
helper takes an optional parameter builder
, which is where we can pass a builder class.
In the example app, our show view invokes it this way:
<%= form_with model: @adventurer, builder: MyFormBuilder do |f| %>
Another place you might consider invoking your builder is as a project-wide default:
# app/helpers/application_helper.rb
module ApplicationHelper
ActionView::Base.default_form_builder = MyFormBuilder
end
If you use the default, form_with
doesn't need to be given a builder option.
Which way should you go? As always, it depends!
If you're building greenfield or have very few form views, setting a default makes a lot of sense!You can get a uniform appearance on every form you build and set up an effective pattern to customize each one as you go.
If you've got lots of pre-existing Rails views / forms you're wrangling, it may be preferable to go view-by-view, manually passing your builder option. Drying up your forms this way can help untangle structure from appearance styling. It may also reveal if there are multiple "types" of forms in your domain you'd like to visualize differently.
Working towards a default implementation might be a decent goal, or you may need to keep working with a few different form styling options.
Regardless, the implementation of our first custom builder will be the same!
Implementing our builder
In Working Effectively With Legacy Code, Michael Feathers describes the concept of a code seam.
A seam is a place where two parts of a system interact, and we can introduce some new behaviour. We're going to create our own seam in the FormBuilder api.
First, we define our builder and inherit from the default FormBuilder
provided by ActionView
.
# /app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
end
end
I've opted to namespace this example, but you don't have to.
Our ultimate goal is to give the form_with
block access to each of the input field methods.
For example:
form_with model: @adventurer do |f|
f.label :adventurer_name, "Adventurer Name", class: "block text-gray-500
font-bold md:text-right mb-1 md:mb-0 pr-4"
f.text_field :name, class: "mt-1 block w-full rounded-md shadow-sm
focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
f.label :adventurer_city, "Adventurer City", class: "block text-gray-500
font-bold md:text-right mb-1 md:mb-0 pr-4"
f.text_field :city, class: "mt-1 block w-full rounded-md shadow-sm
focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
end
The easiest first step to drying this up is to define a text_field
method on our custom builder that does all the styling.
# /app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, options={})
default_style = "mt-1 block w-full rounded-md shadow-sm focus:ring
focus:ring-indigo-200 focus:ring-opacity-50"
super(method, options.merge({class: default_style}))
end
end
end
What's happening here?
We're moving our Tailwind class string into the text_field
method and out of our view.
Then we leverage our inheritance powers to call the text_field
method that Rails already provides for us on the default ActionView::Helpers::FormBuilder
, by invoking super
.
Now we can clean up our view!
form_with model: @adventurer do |f|
f.label :adventurer_name, "Adventurer Name", class: "block text-gray-500
font-bold md:text-right mb-1 md:mb-0 pr-4"
f.text_field :name
f.label :adventurer_city, "Adventurer City", class: "block text-gray-500
font-bold md:text-right mb-1 md:mb-0 pr-4"
f.text_field :city
end
All of our text fields get the same styling applied.
We can wash-rinse-repeat with the label too!
# /app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, options = {})
default_style = "mt-1 block w-full rounded-md shadow-sm
focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
super(method, options.merge({class: default_style}))
end
def label(method, text = nil, options = {})
default_style = "block text-gray-500 font-bold md:text-right
mb-1 md:mb-0 pr-4"
super(method, text, options.merge({class: default_style}))
end
end
end
Note that Labels have a slightly different set of parameters than text_field
. Now our view is tidy:
form_with model: @adventurer do |f|
f.label :adventurer_name, "Adventurer Name"
f.text_field :name
f.label :adventurer_city, "Adventurer City"
f.text_field :city
end
This could seem like a reasonable place to stop. Unfortunately, if we do stop here, we're making more problems.
Our form is now dependent on the TailwindFormBuilder
to create all the styling. It's not unreasonable to imaginewe could end up with a builder per form. At this stage, we are adding indirection and complexity for less benefit than just copy-pasting our styling. To make matters worse, we're making every text_field
co-dependent.
Changes to one affect them all.
So, we must keep going!
Our current example has two main dependencies we need to overcome:
- All of the styling is in the builder method. We can't adjust or modify each
text_field
- We have to write a method for every input type we want to be styled
If we could modify each input field in isolation, our builder code would become more reusable.
The second dependency might seem small, but there are 24 input types. Fortunately, 17 of them act exactly like a text_field
. We can manage a lot of input types in the same way.
For anything unique in its behaviours, like a select
, or a check_box
, we'll still lean on manually defining our methods. Those unique fields have their own api to interact with.
Breaking the first dependency moves us to reusable code, and breaking the second helps our builder be concise.
Let's start with allowing changes to individual text_field
calls. We'll see how the wrap with structure, fill with style pattern in action.
Later we'll include that behaviour on all 17 text-like fields.
Wrap with structure, fill with style
What makes the two forms different? What's the same between them?
At its base, a form is just a collection of inputs that each point to a specific attribute we want to record.Regardless of the underlying model, there are only so many ways to capture the data on the page.
Input elements have a lot of overlap. We want all of our inputs to have a consistent look to them so they're intuitive. They should all have the same font, border styling, color, etc.
Forms will appear in different parts of the user flow. A form with 20 inputs needs to be organized in a different way than a 3-input form. Layout is important for flow and usability, so each needs control of its own structure.
Appearance should be the same across different forms, while the layout should shift to accommodate the information being captured. Splitting up those two concerns gives consistent branding with dryCSS classes, with the flexibility to structure the layout on each form independently.
We intuitively use views this way. We expect a partial to hold the same shape wherever it is rendered.A partial innately has responsibility for structure. Giving the FormBuilder responsibility for appearance styling lets us pair FormBuilders with partial views to hit the sweet spot of dry code and functionality. The input fields appear consistently the same regardless of the form but will be shaped to fit a specific form's need.
Our text_field
method is currently doing both:
# /app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, options={})
default_style = "mt-1 block w-full rounded-md shadow-sm focus:ring
focus:ring-indigo-200 focus:ring-opacity-50"
super(method, options.merge({class: default_style}))
end
end
end
Our structure styling:mt-1 block w-full
and appearance styling:rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50
Our next change: leave the appearance styling here, but lift our structure styling back up to the view.
Mix builder appearance styling with view-level structure styling
Our text_field
interface allows us to pass in a model attribute and a hash of all our options.
Here's what our view should look like:
form_with model: @adventurer do |f|
f.text_field :name, class: "mt-1 block w-full"
end
In this example, we're calling for a field tied to the name
attribute on the @adventurer
. Everything passed after name
gets bundled into a hash.
To allow styling to be handled from both the view and the builder level, we need to isolate the class
passed from the view with our structure styling. Then we need to combine it with the appearance styling declared in our builder.
# /app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
def text_field(method, options = {})
style_options, custom_options =
partition_custom_opts(options)
style = "rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
+ " #{style_options[:class]}"
super(method, options.merge({class: style}))
end
CUSTOM_OPTS = [:class].freeze
def partition_custom_opts(opts)
opts.partition { |k, v| CUSTOM_OPTS.include?(k) }.map(&:to_h)
end
end
end
Partitioning out the :class
from our other options gives us access to do just that.I've opted to use a template literal, so we have some safety if no class
is given from the view.
Our resulting HTML in the browser has both the view-layer structure styling matched with the appearance styling from the builder:
<input class="rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50 mt-1 block w-full"
type="text" value="Gandalf the Grey" name="adventurer[name]" id="adventurer_name">
One dependency broken!
If we apply a similar change to our labels, it allows our view to manage the structure only:
<%= form_with model: @adventurer do |f| %>
<div class="md:flex md:w-full md:justify-between md:items-center mb-6">
<%= f.text_field :name, class: "min-w-1/2" %>
<%= f.label :name, class: "mb-1 md:mb-0 pr-4" %>
</div>
<% end %>
The sample repo does some clever business to bundle up label creation within text_field
and other helpers.Next, we'll make changes, so we're not manually writing all 17 text_like
field helpers. That enables us to handle labels within the builder.
Onto making a concise implementation of all 17 text_like
fields!
A pinch of metaprogramming
If you’re comfortable with ruby metaprogramming, this is the part where we’re shamelessly stealing from the Rails source for generating these methods. If you’re less comfortable, let’s talk about the purpose metaprogramming serves here and how we move our implementation toward utilizing it.
Under the hood, the ActionView::Helpers::FormBuilder
utilizes metaprogramming to keep itself tidy. The whole list of input types is here if you’d like to look at them all. The ActionView FormBuilder
bundles how it defines all the methods that behave like a text_field
and, on initialization, generates an instance method to handle each type of input.
Our builder can mimic this behaviour to add our styling to all the text-like fields, then call for the same method on the ActionView FormBuilder
we’re inheriting from to use the underlying functionality.
We're going to adjust our implementation to:
- use
class_eval
to generate methods for us - include two paths through every method; one for applying styling, one for pointing back to the default behaviour
Here's the big hop toward that new approach:
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
field_helpers - [:label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field].each do |field_method|
class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1
def #{field_method}(method, options = {})
if options.delete(:tailwindified)
super
else
text_like_field(#{field_method.inspect}, method, options)
end
end
RUBY_EVAL
end
def text_like_field(field_method, object_method, options = {})
style_options, custom_options =
partition_custom_opts(options)
style = "rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
+ " #{style_options[:class]}"
send(field_method, object_method, {
class: style,
}.compact.merge(opts).merge({ tailwindified: true }))
end
end
end
What changed? Here we:
- call for
field_helpers
, which comes fromActionView::Helpers::FormBuilder
- subtract a few methods from
field_helpers
that don't behave like atext_field
- add our
class_eval
loop, which handles our metaprogramming - rename our
text_field
method totext_like_field
to show we're handling many different types - adjust
text_like_field
to callsend
instead ofsuper
- introduce an option called
tailwindifed
to control our flow
class_evals
, how do they work?
This class_eval
method is almost lifted directly from the Rails source. Thank you, Rails gods.
By tapping into the metaprogramming aspect of Ruby, we can create all the methods that act like a text_field
in a dry way. We’re creating a method for each text_like
field that knows how to apply styling, then point to the default Rails behaviour for that input type.
field_helpers
is defined on the parent class, ActionView::Helpers::FormBuilder
. Our specific selection here
field_helpers - [:label, :check_box, :radio_button, :fields_for, :fields, :hidden_field, :file_field]
creates an array of all the field_helpers
that act the same way as our text_field
input does.
Then we loop through our list and generate a method for each on our builder.When an instance of the TailwindFormBuilder
is established, the object in memory will look like this:
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
# the class eval loop is here
# def text_like_field ...
def text_field(method, options={})
if options.delete(:tailwindified)
super
else
text_like_field(:text_field, method, options)
end
end
def email_field(method, options={})
if options.delete(:tailwindified)
super
else
text_like_field(:email_field, method, options)
end
end
# and so on, for all of the other 15 text-like field methods
end
end
We’ve captured our previous code that partitions the class
option and combines it with our default styling to reuse that functionality for every text-like field. We’re also letting each text field refer to the default Rails behaviour by calling super
in the method. For example, ActionView::Helpers::FormBuilder
has an email_field
we can invoke by calling super
in an email_field
method on our own builder.
Where does tailwindified
come into play?
I pulled a fast one on you. We’re doing some light recursion. Each method has two flows. The first invocation will add our styling and include a new key in the options hash called tailwindified
.
Once that styling is applied, it calls for the original functionality on the default builder by calling super
.
The flow goes like this:
- We create a
text_field
in the view - Our custom builder's
text_field
method is called - The
text_field
method callstext_like_field
text_like_field
applies our styling and adds thetailwindified
optiontext_like_field
usessend
to calltext_field
again on our ownTailwindFormBuilder
- This time,
text_field
callssuper
, asking for the default Railstext_field
behaviour and passing the options along.
By abstracting the name of the input type, and the two pathways, we're able to tie into all of the input fields that behave similarly to text_input
, style them, and keep our FormBuilder
fairly concise.
This might be where you decide to get off the train! We've got the tie into FormBuilders down.We can leverage the wrap with structure, fill with style pattern I've been encouraging, and get a decent amount of value in doing some simple drying up of our Tailwind.
That said, I've got a couple more suggestions for ways you can lean into both this pattern and some clever tie-ins toRails magic. Validation errors and labels!
Error styling
The reason I like this approach is we get to use our expected Rails API. If a user fails a form validation, we want to show them which field was incorrect with styling and possibly labels.
One major advantage of ActionView is getting to interact elegantly with our model and its built-in validations.
Here are a couple jumps that will bring our styling a bit closer to the sample app and give us a nice seam to respond to model errors.
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
# no changes to the other stuff
def text_like_field(field_method, object_method, options = {})
style_options, custom_options =
partition_custom_opts(options)
style = "rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
classes = apply_style_classes(style, style_options, object_method)
send(field_method, object_method, {
class: classes,
}.compact.merge(opts).merge({ tailwindified: true }))
end
def apply_style_classes(classes, style_options, object_method = nil)
classes + border_color_classes(object_method) + " #{style_options[:class]}"
end
def border_color_classes(object_method)
if errors_for(object_method).present?
" border-2 border-red-400 focus:border-rose-200"
else
" border border-gray-300 focus:border-yellow-700"
end
end
def errors_for(object_method)
return unless @object.present? && object_method.present?
@object.errors[object_method]
end
end
end
What changed? Now:
- we store our appearance styling in a variable in
text_like_field
text_like_field
leans on another method for composing the whole style stringapply_style_classes
took over blending structure styling from the view into the appearance stylingour builder provides- a new
border_color_classes
method adds a red border to the input if errors are present on the model attribute - a helper method to check for errors on the object
Rails magic! If you're using a flash[:error]
on your form view, this helps capture the styling on the formto show users where your message relates.
Labels
If you poke at the sample project, you may note that text_like_field
returns two things: labels + field
. FormBuilder
methods are only required to return HTML that attaches to our view, so we can bundle as many together as we’d like. The Rails docs use automatic labeling as an example for custom form builders.
We can provide wrap with structure, fill with style treatment to our label generation by making small adjustments:
- Allow
text_like_field
to partition out label options, similar to our inline class - Adjust
text_like_field
to return both a label and the input element.
Partition
# /app/lib/form_builders/tailwind_form_builder.rb
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
# ... the other things here
CUSTOM_OPTS = [:class, :label].freeze
def partition_custom_opts(opts)
opts.partition { |k, v| CUSTOM_OPTS.include?(k) }.map(&:to_h)
end
end
end
The partition can grab both class
and label
from the options hash, passed from the view.
Programmatic labels
Now we can generate some labels from the options hash:
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
# ... no changes to the other things!
def text_like_field(field_method, object_method, options = {})
custom_opts, opts = partition_custom_opts(options)
style = "rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
classes = apply_style_classes(style, custom_opts, object_method)
field = send(field_method, object_method, {
class: classes,
title: errors_for(object_method)&.join(" ")
}.compact.merge(opts).merge({tailwindified: true}))
label = tailwind_label(object_method, custom_opts[:label], options)
label + field
end
def tailwind_label(object_method, label_options, field_options)
text, label_opts = if label_options.present?
[label_options[:text], label_options.except(:text)]
else
[nil, {}]
end
label_classes = label_opts[:class] || "block text-gray-500 font-bold"
label_classes += " text-yellow-800 dark:text-yellow-400" if field_options[:disabled]
label(object_method, text, {
class: label_classes
}.merge(label_opts.except(:class)))
end
end
end
What changed?
text_like_field
returns both an input, and a label for the input- we pass any
label
options provided to a helper method,tailwind_label
tailwind_label
chooses defaults, so we safely do nothing if nolabel
is provided at the view level- we apply the appearance style to the label and call for the default
label
method
Now our view can provide a label for input fields as an option.
<%= form_with model: @adventurer, builder: FormBuilders::TailwindFormBuilder, do |f| %>
<div class="md:flex md:w-full md:justify-between md:items-center mb-6">
<%= f.text_field :name, label: { text: "Full Name" } %>
</div>
<% end %>
On its own, this feels like a small sidestep in functionality from the f.label
approach, so I understand ifit's not particularly appealing to you. One thing it does enable is to add error labels to our form inputs using the same API.
module FormBuilders
class TailwindFormBuilder < ActionView::Helpers::FormBuilder
# ... no changes to the other things!
def text_like_field(field_method, object_method, options = {})
custom_opts, opts = partition_custom_opts(options)
style = "rounded-md shadow-sm focus:ring focus:ring-indigo-200 focus:ring-opacity-50"
classes = apply_style_classes(style, custom_opts, object_method)
field = send(field_method, object_method, {
class: classes,
title: errors_for(object_method)&.join(" ")
}.compact.merge(opts).merge({tailwindified: true}))
labels = labels(object_method, custom_opts[:label], options)
labels + field
end
def labels(object_method, label_options, field_options)
label = tailwind_label(object_method, label_options, field_options)
error_label = error_label(object_method, field_options)
@template.content_tag("div", label + error_label, {class: "flex flex-col items-start"})
end
def error_label(object_method, options)
if errors_for(object_method).present?
error_message = @object.errors[object_method].collect(&:titleize).join(", ")
tailwind_label(object_method, {text: error_message, class: " font-bold text-red-500"}, options)
end
end
end
end
Overall this kinda feels clever to me. I don't love being clever. I have a strong preference for writing explicit code and have a high tolerance for repetitively typing f.label
in my views.
Ultimately I'll leave it as an exercise to you to decide if the cleverness of auto-labeling is a win or not.
So much of it is context-dependent.
I'll also leave the caveat that we might blend appearance/layout concerns with labels. I'm still reconciling that and need to try this pattern out on some larger projects before I give a final verdict.
However you form, form smoothly
Every time I sit down to write, I end up with a whole novel. So, thanks for sticking it out.
I'm not going to prescribe the error styling or label generation extensions for your forms. Your project will have its own unique qualities that will determine if those are relevant to you or not.
I do hope this dive into the way Rails builds forms and how wrap with structure, fill with style helps smooth out your experience building forms. Or even inspires you to do some more cool things! I haven't even touched on things like Turbo or some of the deeper integration between forms and their underlying models. If you build a cool thing with this pattern, I'd love to see it!
Ultimately, I just want us all to have forms that serve us, feel intuitive, and don't require us to spend more time than necessary on the cruft. This might not be the pattern for you or for your project, but maybe it'll help.