This post addresses a simple need: adding a "required" text next to any form field label that is… required.
But I wanted a solution:
- using only TailwindCSS/TailwindUI existing classes
- that can be reused easily
- internationalization friendly (because we're using a word - required - and not the "*" sign as you often see)
- that doesn't reinvent the wheel in some way
- that looks like this
For the coder in a hurry, my solution
The gist of it is label_builder.translation
.
= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500" do |label_builder|
= label_builder.translation
span.ml-2.normal-case.text-orange-400.text-xxs.font-semibold= t("required")
= f.email_field :email, autocomplete: false, required: true, class: "form-input mt-1 block w-full text-field focus:shadow-outline focus:border-green-300", placeholder: "richard@piedpiper.com"
Note: I use the Slim template language instead of ERB; it's easier to read so I trust that even if you've never heard of it you won't be lost.
For the curious mind, how I got there and why
FAIR WARNING: the rest is not your typical technical article. You already have the solution above, the rest is about how I found the solution. So it's more a story than a guide.
Here I was setting up a simple profile form, adding the proper required: true
attributes and looking at it when I realized: how the hell would a user know which fields are required and which aren't?
Like anyone, anytime I see a form, my mind looks for the easy way out and tries to figure out how to hit that submit button as quickly as possible.
This bland form triggered my fill or flight response and I knew I had to do something about it.
Starting point
With a bit of TailwindCSS, here is what it was looking like.
and the code
= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500"
= f.email_field :email, autocomplete: false, required: true, class: "form-input mt-1 block w-full text-field focus:shadow-outline focus:border-green-300", placeholder: "richard@piedpiper.com"
It's as basic as it gets: a label and an email_field with some classes.
I will skip the TailwindCSS explanations, just know that "form-input" comes from a plugin.
Goal check:
- ✅ using only TailwindCSS/TailwindUI existing classes
- ❌ that can be reused easily
- ❌ internationalization friendly
- ❌ that doesn't reinvent the wheel in some way
- ❌ that looks like the cover image
Approach n°1: Add a "*" after the email
Often, forms signal that a field is required by appending an asterisk to the label title, which can be done with a bit of CSS.
.required:after {
content:" *";
}
I went to the TailwindCSS docs and couldn't find anything ready-made to handle this.
Sure, I could add it to my stylesheet files, but I already had another idea in mind.
I played a little bit with Basecamp recently as I watched the On Writing Software (well?) videos by DHH. And it reminded me that using the word "required" instead of a simple "*" sign is great to improve clarity.
Approach n°2: Appeal to humanity
This confusing title is a bad pun inspired the method we'll be using in this approach: Model.human_attribute_name
.
Since label
can take a block to render, it's easy to come up with a first solution.
So I went from
= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500"
to
= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500" do
| Email
span.ml-2.normal-case.text-orange-400.text-xxs.font-semibold required
OK. It looks like the final result, but we're not quite there yet, because both "Email" and "required" are hardcoded.
Goal check:
- ✅ using only TailwindCSS/TailwindUI existing classes
- ❌ that can be reused easily
- ❌ internationalization friendly
- ❌ that doesn't reinvent the wheel in some way
- ✅ that looks like the cover image
The "required" text doesn't need much talking about. We just have to replace it with = t("required")
and add a translation somewhere, probably in config/locals/en.yml
since it will be pretty generic.
en:
required: required
But what about the email? I needed a solution that would be a bit more generic.
The standard way to look up translations for any attribute is Model.human_attribute_name(:attribute)
, so I did just that.
= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500" do
/ 👍 Not horrible
= f.object.class.c(:email)
span.ml-2.normal-case.text-orange-400.text-xxs.font-semibold= t("required")
= f.email_field :email, autocomplete: false, required: true, class: "form-input mt-1 block w-full text-field focus:shadow-outline focus:border-green-300", placeholder: "richard@piedpiper.com"
At this point, I am relying on common techniques. A little bit unsatisfying but it gets the job done.
What did I find unsatisfying, you may wonder?
Generally speaking, rails take care of many intricacies you might not think of. When you're replicating part of a method (in our case the part that takes a symbol - :email - and turns it into text - Email -), it's highly likely you're forgetting edge cases or oversimplifying.
It's like using a steering wheel versus ropes tied to the wheels. Sure, it works, you have a direct grip on things, but there's a reason we built an intermediary thingy.
In particular, by doing our own thing instead of relying on rails' wisdom, we're missing out on lazy lookup cleverness.
And in our case, by using Model.human_attribute_name
directly, we are reinventing the wheel.
Goal check:
- ✅ using only TailwindCSS/TailwindUI existing classes
- ✅ that can be reused easily
- ✅ internationalization friendly
- ❌ that doesn't reinvent the wheel in some way
- ✅ that looks like the cover image
Final approach
Where to go from there? I knew that the label
helper was handling the translation at some level, and I wanted to know if you could tap into it.
There was two way to deal with this. The easy way, and the way I did it because… I got carried away.
Let's start with the laborious (but still interesting) way
The easiest way to know how the label
helper handles the translation is to look at the source code
Using Dash, I opened the rails source code and found this
def label(method, text = nil, options = {}, &block)
@template.label(@object_name, method, text, objectify_options(options), &block)
end
I clicked the label call and looked at the definitions found by Github's code navigation (I only discovered recently that Github lets you do that, so I'm mentioning it here in case you didn't know about).
The method's signature of the first row seems to match the one used before, so I followed it and found this, which doesn't do much:
def label(object_name, method, content_or_options = nil, options = nil, &block)
Tags::Label.new(object_name, method, self, content_or_options, options).render(&block)
end
From there, I followed Tags::Label
and finally arrived on the LabelBuilder
Here I started to skim through the code, but I didn't have to go far because, at the very top, I saw a promising def translation
.
I went back to the code, added a parameter to the block call that I felt should be named label_builder, and called its translation method.
= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500" do |label_builder|
/ ✨ True magic
= label_builder.translation
span.ml-2.normal-case.text-orange-400.text-xxs.font-semibold= t("required")
= f.email_field :email, autocomplete: false, required: true, class: "form-input mt-1 block w-full text-field focus:shadow-outline focus:border-green-300", placeholder: "richard@piedpiper.com"
This translation is closer to the template engine than the previous solution.
The easy way
Before we go further, here is the easy way to find that same method in less time.
You just have to a) use the console
= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500" do |label_builder|
- console
b) load the page in your browser, wait for the console to appear, and type label_builder.methods
to return the list of the names of methods of our label_builder: :translation
is the very first one.
Why didn't I think of that? Force of habits mostly.
I've found a lot of solutions recently by reading code so it was my first instinct. Also, I didn't think it would be that easy, ie that the LabelBuilder
would just expose the translation.
But I must admit that the prospect of reading code and learning a thing or two because of it was also appealing. I find that it's always worth my time.
Why is it better and what did we learn?
Remember when I told you that we were missing out on lazy lookup cleverness?
You can reveal it using i18n-debug.
The previous solution using human_attribute_name
looks for this key:
en.activerecord.attributes.contact.email
Our new solution first looks for one specific to labels and only falls back on the model one if it's nil.
en.helpers.label.contact.email
en.activerecord.attributes.contact.email
This is way more satisfying 😌 and the end of the original article.
I wanted to not only share my solution but also the tools I use as well as a way to go beyond duck-taping (which to me is any approach up to - and including - the human_attribute_name
approach).
At this point you know how to add a "required" text to form labels:
- ✅ using only TailwindCSS/TailwindUI existing classes
- ✅ that can be reused easily
- ✅ internationalization friendly
- ✅ that doesn't reinvent the wheel in some way
- ✅ that looks like the cover image
For the Stakhanovites, going further with a Form Builder
I didn't plan to go that far initially, but I couldn't resist once I thought about it 😬
If you're going to do that often, you'll probably want to start customizing form builders in order to make all of this even more reusable, write less code and get even closer to vanilla rails.
Indeed, with a form builder we can simply write something like this:
= f.label :email, required: true, class: "block text-xxs uppercase text-gray-500", required_class: "ml-2 normal-case text-orange-400 text-xxs font-semibold"
= f.email_field :email, autocomplete: false, required: true, class: "form-input mt-1 block w-full text-field focus:shadow-outline focus:border-green-300", placeholder: "richard@piedpiper.com"
If you compare it to our starting point, you'll see that there is only one difference: an extra required_class
.
Now, this is 😎.
What would that form builder look like? Here's my take on this. It's the first form builder I write, so if you have more experienced and notice something weird please do tell.
# app/form_builders/requiring_form_builder.rb
class RequiringFormBuilder < ActionView::Helpers::FormBuilder
def label(method, text = nil, options = {}, &block)
text_is_options = text.is_a?(Hash)
required = text_is_options ? text[:required] : options[:required]
if required
required_class = text_is_options ? text.delete(:required_class) : options.delete(:required_class)
super(method, text, options) do |label_builder|
@template.concat label_builder.translation
@template.concat @template.content_tag(:span, I18n.t("required", scope: :helpers), class: required_class)
@template.concat(@template.capture(label_builder, &block)) if block_given?
end
else
super(method, text, options, &block)
end
end
end
There are two other things required for this to work.
First, the form_with declaration must declare the builder:
= form_with model: @user, builder: RequiringFormBuilder do |f|
Second, you might have noticed that I use I18n.t("required", scope: :helpers)
and not I18n.t("required")
as we did before because it seems more appropriate to put that under the helpers
namespace (in my case in a file called config/locals/helpers/en.yml
). So you need to move that in the proper translation file, or at the minimum to change
en:
required: required
into
en:
helpers:
required: required
I won't go into the details, but I'll point to two parts in the rails code that helped me write this code.
- https://github.com/rails/rails/blob/a0e0f0263902896a7aacf703bcab35bee16bdaf8/actionview/lib/action_view/helpers/tags/label.rb#L30 because text can, in fact, be the options
-
https://github.com/rails/rails/blob/477fae3eb3d3b3bfdbe28586fecb8578c0be4721/actionview/lib/action_view/helpers/form_helper.rb#L1924 because we don't want to break block calling (
do |label_builder|
)
With this form builder, we can now simply add a required_class
to any required label field.
If you enjoyed this article, you can follow me on Twitter @sowenjub.
Top comments (0)