Insight | Nov 29, 2016
Theming form elements in Drupal 8
By Ross Keatinge
I recently worked on a Drupal 8 project which involved a lot of theming of form elements. That sounds simple right? Just edit some templates and preprocess functions and you’re done. That’s basically true but, like many things, it can be confusing and frustrating at first when things don’t quite behave as expected.
While I don’t think it’s necessary to know all the inner workings of the rendering system unless that is something that really interests you, you do need to know the basic operation, especially such things as the order of execution. I learned a lot by studying how a simple form element is rendered and setting myself an exercise to modify the result.
I’m assuming that the reader knows how to build a form class and create a controller and route to it. There are lots of tutorials online for that. Drupal Console is a great tool for generating this sort of boilerplate code.
Let’s build a test form with a simple textfield.
$form['mytext'] = [ '#type' => 'textfield', '#title' => 'My Text', ];
Show that on a page and use theme debugging to examine the hooks and templates.
There are three theme hooks and templates involved.
The outer hook is form_element
which has a template of form_element.html.twig.
Contained inside it is the output from two other templates. These are:
- The label from hook
form_element_label
and a template ofform_element_label.html.twig
. - The actual input element itself from
input__textfield
and a template ofinput.html.twig
.
These all follow a slightly different path through the rendering pipeline.
Data returned from Drupal\Core\Render\Element\Textfield::getInfo()
is used to set default values in the final render array based on the #type of ‘textfield’. Among the values set is the #theme of input__textfield
.
The core templates are in core/modules/system/templates
. I went there expecting to find input—textfield.html.twig
but there is no such file. The rendering code recognizes double underscores in the hook name and progressively slices parts off the end looking for a less specific template. In this case it finds input.html.twig
.
The elegance of this is that input.html.twig
renders all the basic form elements because they’re all just an input tag. The only difference between a text field and a radio button are the input tag’s attributes. Of course if you want to do something special, you can create input—textfield.html.twig
in your theme and that will be used instead. It’s effectively a build-in theme suggestion without having to implement hook_theme_suggestion
.
Now that we have the basic input tag rendered, how does the label get rendered and associated? A look at the render array after the defaults are set based on the #type will show that form_element
is a theme wrapper. This is rendered next and is supplied with the already rendered result of the input element. There is a template_preprocess_form_element()
function in form.inc which, among other things, constructs a render array for the label based on values such as the #title of the textfield in the form definition. This render array is given a #theme value of form_element_label
and is supplied to form_element.html.twig
in a variable ‘label’ which is rendered in the template with {{ label }}
. Drupal’s Twig extensions give the ability to render either a string or a render array with {{ }}
.
All the usual theming techniques are available to us. The most obvious being custom templates or the following preprocess functions. We can modify the variables supplied to the template, add new variables etc. Substitute your own theme name.
function mytheme_preprocess_input(&$variables) { } function mytheme_preprocess_form_element(&$variables) { } function mytheme_preprocess_form_element_label(&$variables) { }
The order of execution is important and usually explains the reason why something doesn’t have the desired effect. Let’s say you want an additional CSS class on the input tag. Perhaps you’re already working in mytheme_preprocess_form_element()
. You start looking at the $variables array and see the existing classes in $variables['element']['#attributes']['class']
. It’s tempting to try:
$variables['element']['#attributes']['class'][] = 'my-class';
But that has no affect because it’s too late, the input tag has already been rendered. The place to do it is in mytheme_preprocess_input, before the tag is rendered. The correct code there is:
$variables['attributes']['class'][] = 'my-class';
Preprocess functions in core copy important variables to the top level. If you need to drill down into $variables['element']
to find what you want to change, that’s a good sign that you’re in the wrong function.
A learning exercise: Wrapping labels around the element
I wanted to give my form elements the option of wrapping the label around the element. This seems to be a common pattern for checkboxes.
Instead of:
<input id=“my-checkbox-id” name=“my-checkbox-name” type=“checkbox” value=“1”> <label for=“my-checkbox-id”>Click here</label>
I wanted:
<label> <input id=“my-checkbox-id” name=“my-checkbox-name” type=“checkbox” value=“1”> Click here </label>
I’ll leave it to my front end developer colleagues to argue the pros and cons of the two patterns. If it means anything, the popular Bootstrap framework uses the wrapped version. I don’t think either way is right or wrong but it provides a good exercise in using the knowledge described above. I went through several iterations of how to do this before getting to the following, which I think is a reasonably elegant solution that avoids major changes to templates.
Let’s invent a new boolean property, #wrapped_label
for a form element to use when defining a form. A checkbox might look like:
$form['my-checkbox'] = [ '#type' => 'checkbox', '#title' => 'Click here', '#wrapped_label' => TRUE, ];
We need to customize the rendering of form_element
. I implemented hook_theme_suggestions_form_element_alter
to “suggest” using a different template when we want a wrapped label.
function mytheme_theme_suggestions_form_element_alter(array &$suggestions, array $variables) {
if (!empty($variables['element']['#wrapped_label'])) {
$suggestions[] = 'form_element__wrapped';
}
}
Now we can implement a preprocess function for this template.
function mytheme_preprocess_form_element__wrapped(&$variables) {
$variables['label']['#theme'] = 'form_element_label__open';
$variables['label_open'] = $variables['label'];
unset($variables['label']);
$variables['title'] = $variables['element']['#title'];
}
The significant line here is the first one, which assigns a new #theme to the label. This will let us use a different template. Moving from a key of label to label_open is not strictly necessary but is done in the interests of code clarity. We’re going to render a “label open”, i.e., a label without a closing tag, so let’s not call it a label. We also copy the title to the top level so it can be easily rendered in our wrapper template rather than in the label_open
template.
Here’s the main containing div part of my form-element—wrapped.html.twig
.
<div{{ attributes.addClass(classes) }} xmlns="http://www.w3.org/1999/html"> {{ label_open }} {% if title_display == 'before' %} {{ title }} {% endif %} {% if prefix is not empty %} <span class="field-prefix">{{ prefix }}</span> {% endif %} {% if description_display == 'before' and description.content %} <div{{ description.attributes }}> {{ description.content }} </div> {% endif %} {{ children }} {% if suffix is not empty %} <span class="field-suffix">{{ suffix }}</span> {% endif %} {% if label_display == 'after' %} {{ title }} {% endif %} </label> {% if errors %} <div class="form-item--error-message"> {{ errors }} </div> {% endif %} {% if description_display in ['after', 'invisible'] and description.content %} <div{{ description.attributes.addClass(description_classes) }}> {{ description.content }} </div> {% endif %} </div>
And here’s the main part of my form-element-label--open.html.twig
.
{% if title is not empty or required -%} <label{{ attributes.addClass(classes) }}> {%- endif %}
And there you have it. Labels are wrapped or not depending on a new attribute in the form array.
Drop us a line
Have a project in mind?
Contacting Third and Grove may cause awesomeness. Side effects include a website too good to ignore. Proceed at your own risk.