Custom Drupal Elements for Forms

Posted on July 8, 2013, 8:41 p.m.

One of the most cryptic tasks in Drupal is defining a custom form element. That is, defining your own element types to be used and resused in your forms. This post will walk you through the basics. And there's no better way than to learn by example.

For our example element, let's define one that links to a github or bitbucket repository. These two sites host a lot of open source projects, so it makes sense to have an element that handles both. Note: this guide applies to Drupal 7. You should be able to make it work on Drupal 6 with some minor modifications. Let's call our element gitbucket.

Learning Drupal? Subscribe to my Drupal articles, tips and tutorials.

A custom Drupal element in use. A custom Drupal element in use.

Part of what makes the Form API in Drupal so powerful is that we can define compound custom elements. In other words, elements that contain one or more existing elements. This is quite useful for our use case, where we want our gitbucket field to have both, a source field (github or bitbucket) and a username/repository field. So let's get started.

The key to creating a custom element is hook_element_info(), where we provide information about our element. So let's define it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<?php

function example_element_info() {
  $elements['gitbucket'] = array(
    '#default_value' => '',
    '#input' => TRUE,
    '#process' => array('example_gitbucket_element_process'),
    '#theme' => array('gitbucket_field'),
    '#theme_wrappers' => array('form_element'),
    '#tree' => TRUE,
    '#value_callback' => 'example_gitbucket_element_value_callback',
  );
  return $elements;
}

As you can see above, we're providing the Form API with quite a bit of info. Let's walk through this hook step by step.

Our #process callback is what gets called when we're going to use the element in a form. This is where we turn our element from a simple definition into the compound element that we want. So we provide a callback function for it. We'll get to that in a bit.

We also define a #theme callback. This is what renders the inside of our element, including the subelements that we'll define. We have a lot of flexibility here when rendering. Using this callback, we'll make sure our element looks nice and readable.

Next we provide #theme_wrappers. As you'd imagine, this wraps all the elements after they come back from the #theme callback. Generally, you can just use the built-in form_element and let it do the work for you. That's exactly what we're doing here.

Last but not least, we define #tree and #value_callback. The first makes sure our nested element structure is preserved for our values, rather than flattened. The second provides a way to process values before they are returned to the form submit function. You need to provide a #value_callback if you want to do fancy processing on the input.

With all of that declared, we can now implement our #process function to construct our very own custom element:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?php

function example_gitbucket_element_process($element, $form_state, $complete_form) {

  $element['gitbucket']['source'] = array(
    '#type' => 'select',
    '#empty_option' => '- ' . t('Source') . ' -',
    '#options' => array(
      'github' => 'github://',
      'bitbucket' => 'bitbucket://',
    ),
    '#required' => $element['#required'],
    '#title' => t('Repository Source'),
    '#title_display' => 'invisible',
    '#theme_wrappers' => array(),
  );

  if (isset($element['#default_value']['gitbucket']['source'])) {
    $element['gitbucket']['source']['#default_value'] = $element['#default_value']['gitbucket']['source'];
  }

  $element['gitbucket']['address'] = array(
    '#type' => 'textfield',
    '#size' => 50,
    '#required' => $element['#required'],
    '#title' => t('Repository Name'),
    '#title_display' => 'invisible',
    '#theme_wrappers' => array(),
    '#attributes' => array('placeholder' => 'username/repository'),
  );

  if (isset($element['#default_value']['gitbucket']['address'])) {
    $element['gitbucket']['address']['#default_value'] = $element['#default_value']['gitbucket']['address'];
  }

  return $element;
}

As you can see, we're nesting elements in the snippet above. Our custom element consists of a select field for the repository source, and a textfield for the actual repository. The actual declaration of these fields is straightforward, and you can use almost anything in the Form API.

When nesting elements, there are a couple of quirks to watch out for. First, you probably don't want a bunch of nested titles shown. That's why we set #title_display to invisible. But we still declare a title for error handling purposes. We want the Form API to be able to pinpoint fields required nested fields and the like.

You also don't want the wrapping HTML structure around each individual nested element. You want it outside the entire compound element. So for each nested element, we set #theme_wrappers to an empty array.

And that's it. We load up default values as necessary, and we're done processing our custom elemenet. Let's move on to theming.

We want a nice inline look for our field, so let's keep the theming simple. First we need to register our function, and then implement it. Like so:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<?php

function example_theme() {
  return array(
    'gitbucket_field' => array(
      'render element' => 'element',
    ),
  );
}

function theme_gitbucket_field($variables) {
  $element = $variables['element'];
  $output = '';
  $output .= drupal_render($element['gitbucket']['source']);
  $output .= " "; // This space forces our fields to have a little room in between.
  $output .= drupal_render($element['gitbucket']['address']);
  return $output;
}

Now we're ready for the last part: the value callback. As you can see, our custom element has two parts. You should select either or none. But what happens if they just select a source with no repository name? If we marked the field as required, Drupal handles that for us by requesting both fields be populated. The problem appears when our field is not marked as required. In this case, if we fill just one and not the other, we want to treat this as the user having left the field empty. We can use the value callback for this 1.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<?php

function example_gitbucket_element_value_callback($element, $input = FALSE, &$form_state) {
  if ($input !== FALSE) {
    if ($input['gitbucket']['source'] && !$input['gitbucket']['address']) {
      $input['gitbucket']['source'] = '';
    }
    if ($input['gitbucket']['address'] && !$input['gitbucket']['source']) {
      $input['gitbucket']['address'] = '';
    }
    return $input;
  }
  elseif (!empty($element['#default_value'])) {
    return $element['#default_value'];
  }

  return;
}

Value callbacks should be divided into three conditions:

  1. Input is being provided directly. This happens when a form is submitted.
  2. No input is provided, but the field definition has a default value.
  3. No input is provided and there is no default value.

In the first part, we handle the edge case of partial input. If only half the field is filled out, we simply treat it as empty. Then we return the modified input.

And that's it. You're done. You've now defined a custom compound field that can be used in any Drupal forms. For example, let's declare a simple system settings form that uses our newly minted element.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<?php

function example_form() {
  $form = array();
  $form['required_field'] = array(
    '#type' => 'gitbucket',
    '#title' => t('Your favorite repository'),
    '#required' => TRUE,
    '#default_value' => variable_get('required_field', array()),
  );
  $form['optional_field'] = array(
    '#type' => 'gitbucket',
    '#title' => t('Your least favorite repository'),
    '#default_value' => variable_get('optional_field', array()),
  );
  return system_settings_form($form);
}

Try it out. Fill in partial input and hit submit. You'll see how our fields behave properly even with missing input.


  1. Technically, you can also use validation hooks for this.