I needed to create theme templates for specific fields in specific forms, and Drupal's theme template structure made the task pretty straightforward. I'm using a name text input field on a form named contact as an example here. The goal was to provide theme templates that could target that specific field, and also for common field types shared within the specific contact form.

Templates needed

Here are the template suggestions that I thought would be useful

input--textfield--contact.html.twig        # all text fields in the `contact` form
input--name--contact.html.twig             # The specific field `name` in the `contact` form

Here are the form field element values I needed to define the suggestions

Generic to specific field definitions
Generic form element type input
Specific Drupal API type textfield
The form id contact
The field name name

Hooks and the Form ID

After some research, I found the Themable Forms module that implements hooks to recursively provide the form id in the build array of form elements. Using this pattern, I implemented hook_form_alter() for providing the form id to my form elements' render arrays, which was the key missing element. I implemented this in my custom module named jcmodule, in the jcmodule.module file:

/**
 * Implements hook_form_alter().
 */
function jcmodule_form_alter(&$form, FormStateInterface $form_state, $form_id) {
  jcmodule_attach_form_id($form, $form_id);
}

/**
 * Attaches form id to all form elements.
 *
 * @param $form
 *   The form or form element which children should have form id attached.
 * @param $form_id
 *   The form id attached to form elements.
 *
 * @return array
 */
function jcmodule_attach_form_id(&$form, $form_id) {
  foreach (Element::children($form) as $child) {
    if (!isset($form[$child]['#form_id'])) {
      $form[$child]['#form_id'] = $form_id;
    }
    jcmodule_attach_form_id($form[$child], $form_id); // recurse for children
  }
}

This defined a new value for #form_id in the $build array for my form elements. Under the hood it looks something like this

$build = [
  "#type" => "textfield",
  "#name" => "name",
  "#title" => "Your full name",
  "#required" => true,
  "#form_id" => "contact",
  "#weight" => 0,
  ...
];

To take advantage of the #form_id values that were added, I implemented a hook in my theme to provide templates for each form element. I used the generic hook, hook_theme_suggestions_alter() so the basic form elements would be covered. This approach provides theme suggestions for input fields, checkbox fields, select fields, etc. Since the hook is generic, wrapping all the logic with the initial check for #form_id will only be called for form elements, and it leaves room for more logic in the body of the same hook function for other element types in the future.

/**
 * Add suggestions by keys 
 * implements hook_theme_suggestions_alter()
 * 
 * @param array $suggestions 
 *      Existing suggestions
 * @param array $variables 
 *      Element variables
 * @param string $hook 
 *      Original hook
 */
function jctheme_theme_suggestions_alter(array &$suggestions, array $variables, $hook) {
  if (
    isset($variables['element']['#form_id'])
    && isset($variables['element']['#type'])
    && isset($variables['element']['#name'])
  ) {
    $element = $variables['element'];
    $formid = str_replace('-', '_', $element['#form_id']);
    $suggestions[] = $hook . '__' . $formid;
    $suggestions[] = $hook . '__' . $element['#type'] . '__' . $formid;
    $suggestions[] = $hook . '__' . $element['#name'] . '__' . $formid;
    $suggestions[] = $hook . '__' . $element['#name'] . '__' . $element['#type'] . '__' . $formid;
  }
}

This provided the suggestions I was initially looking for, and more that could be useful in the future

input--name--contact.html.twig              # any field named `name` in `contact` form
input--textfield--contact.html.twig         # all input text fields in `contact` form
input--contact.html.twig                    # all input fields in `contact` form
input--name--textfield--contact.html.twig   # text field named `name` in `contact` form

To implement, I copied input.html.twig from the stable theme and renamed it to input--name--contact.html.twig using the new suggestion, so now only this field on this form will be rendered with this template.

Last Updated November 26th, 2020