Technical Tuesdays: #theme Form Elements into tables

Submitted by doug on January 22, 2008 - 7:00am.

Forms API (FAPI) is one of the coolest pieces of Drupal.

The power and flexibility of hook_form_alter has made it possible to change the behavior of a module, without actually modifying any code in the original module, and has created a whole new class of "helper" modules that enhance the capabilities of existing modules. But this article isn't about form_alter.

Another powerful feature of FAPI is the #theme attribute. It allows a module to provide the formatting of a form or form element. And it does this within the framework of theming, which also allows the theme to override the display. It's really sweet!

Before I dive into #theme, you should bookmark the forms_api_reference.html page. It shows all of the available form elements and form controls in a helpful table, with links to the full documentation.

This article is about one particular #theme function that I find really handy. I've recently started theming combo boxes and radio buttons in 2 and 3 column tables. This is relatively easy to do, so I thought I'd share the code.

First, you need the following function. I've put it within a if (!function_exists) so that it is safe to use in multiple modules. Ideally this should be part of includes/theme.inc in core, and I will try to get it accepted in the 7.x development cycle.

if (!function_exists('theme_cols')) {
  /**
   * Implement theme_cols to theme the radiobuttons and checkboxes form
   * elements in a table column.
   */
  function theme_cols($form) {
    $total = 0;
    $cols = isset($form['#cols']) ? $form['#cols'] : 3;
    foreach ($form as $element_id => $element) {
      if ($element_id[0] != '#') {
        $total ++;
      }
    }
    $total = (int) (($total % $cols) ? (($total + $cols - 1) / $cols) : ($total / $cols));
    $pos = 0;
    $rows = array();
    foreach ($form as $element_id => $element) {
      if ($element_id[0] != '#') {
        $pos ++;
        $row = $pos % $total;
        $col = $pos / $total;
        if (!isset($rows[$row])) {
          $rows[$row] = array();
        }
        $rows[$row][$col] = drupal_render($element);
      }
    }
    return theme('table', array(), $rows);
  }
}

Then, in your form definition, attach #theme and optional #cols to the form element. For example:

  $form['yourmodule_checkboxes'] = array(
    '#type' => 'checkboxes',
    '#options' => array('A', 'B', 'C', 'D', 'E', 'F', 'G'),
    '#theme' => 'cols',
    '#cols' => 4,
  );

It works for "comboboxes" and "radios" form elements. The default number of columns is 3, so you don't need to add #cols if you want three columns.

I use this code in a few places already. For example, it greatly simplifies the modules fieldset of the coder settings form, as you can see below. I've cut off the picture below, because it's pretty long. But it's three times longer without theming them as a a three column table!

Submitted by GregoryHeller on January 22, 2008 - 8:34am.

Thanks Doug for kicking off our series of Technical Tuesday articles.

Submitted by chx on January 22, 2008 - 9:26am.

You could use foreach (element_children($form) as $key) { $element = $form[$key]; otherwise looks great. Thanks for the tip. To just organize checkboxes / radios in columns is a much better (and saner) approach than trying to mate the full blown theme_table with forms. That really does not want to fly but then again who said we need that? I will support this in core.

Submitted by weitzman on January 22, 2008 - 12:13pm.

nice work, doug. we'd love some help getting this feature into core. please see this issue. there is some good prior art in the 2 sandbox links in the issue.

Submitted by Bevan Rudge on January 22, 2008 - 9:23pm.

This might be a good case for CSS columns, not table cols. Theming them cross-browser compatibly is a bit harder especially with the stripe, but the accessibility is considerably improved.

Ping me when there's a patch for CVS head with this and I'll have a go at a CSS version.

Submitted by arthur on February 1, 2008 - 8:13am.

Here's a stab at the CSS version. Obviously the style stuff should be pulled out, but in an attempt to provide an example, this is what I came up with. From Doug's example, change the theme call at the end of his theme function to use theme('css_table', array(), $rows). Code could probably use some cleanup, but hey, I've only had one cup of coffee.

function theme_css_table($header, $rows, $attributes = array(), $caption = NULL) {
  $output = '<div'. drupal_attributes($attributes) .">\n";

  if (isset($caption)) {
    $output .= '<caption>'. $caption ."</caption>\n";
  }

  // Format the table header:
  if (count($header)) {
    $ts = tablesort_init($header);
    $output .= ' <div class="table_header">';
    foreach ($header as $cell) {
      $cell = tablesort_header($cell, $header, $ts);
      $output .= _theme_table_cell($cell, TRUE);
    }
    $output .= " </div>\n";
  }

  // Format the table rows:
  $output .= "<div class='table_body'>\n";
  if (count($rows)) {
    $flip = array('even' => 'odd', 'odd' => 'even');
    $class = 'even';
    foreach ($rows as $number => $row) {
      $attributes = array();

      // Check if we're dealing with a simple or complex row
      if (isset($row['data'])) {
        foreach ($row as $key => $value) {
          if ($key == 'data') {
            $cells = $value;
          }
          else {
            $attributes[$key] = $value;
          }
        }
      }
      else {
        $cells = $row;
      }

      // Add odd/even class
      $class = $flip[$class];
      if (isset($attributes['class'])) {
        $attributes['class'] .= ' '. $class;
      }
      else {
        $attributes['class'] = $class;
      }

      //add class to the row
      $attributes['class'] .= ' table_row';
     
      // count the number of cells in the row
      // to get CSS width
      $width = 100 / count($row);
           
      // Build row    
      $output .= ' <div '. drupal_attributes($attributes) .'>';
      $i = 0;
      foreach ($cells as $cell) {
        $cell = tablesort_cell($cell, $header, $ts, $i++);
        $output .= '<div class="table_cell" style="float: left; width: '. $width .'%">'. $cell .'</div>';
      }
      $output .= " </div><br style='clear: both;' />\n";
    }
  }

  $output .= "</div></div>\n";
  return $output;
}

Submitted by Bevan Rudge on February 2, 2008 - 6:48am.

Ah, interesting. Take a look at how I did this in justcauseit.com just with some enhancements to an override of theme_item_list and CSS: http://justcauseit.com/#node-1206 (Scroll done to member blogs section)