Third & GroveThird & Grove
Nov 29, 2016 - Ross Keatinge

Theming form elements in Drupal 8

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 of form_element_label.html.twig.
  • The actual input element itself from input__textfield and a template of input.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.