I ended up solving my problem before I got done writing up my question, but figured I'd document the solution in case it's helpful to anyone. Also, if there's a better way, or I missed things, please chime in.

I have a module settings form with multiple format-enabled WYSIWYG textarea elements. By default, the #description defined in the $form array is printed at the bottom of the field output, like this:

  • TITLE
  • WYSIWYG EDITOR (with toolbar, etc.)
  • TEXT FORMAT fieldset
  • **DESCRIPTION**

This makes the #description basically invisible to users.

What I wanted was to have the #description displayed more prominently, just below the field label, so users would have an easier time understanding what the fields are for.

However, I found that this was exceptionally hard to pull off, due to the fancy stuff being done at the filter and WYSIWYG level.

What does NOT work (at least as far as I could tell):

  • Overriding theme_form_element
  • Overriding theme_text_format_wrapper
  • Custom #theme callback
  • Custom #theme_wrappers callback
  • Custom #pre_render callback
  • Custom #after_build callback

Every combination of these either simply didn't work, or broke something. I don't recall all the finer points, but as an example, setting #pre_render on an element causes the WYSIWYG module to not add ITS #pre_render function.

So, here's what I got to work. The basic idea is to use a custom FAPI key to append:
1. Use MODULE_theme_registry_alter to specify a custom function for the form_element_label theme. Copy the original theme registry entry.

/**
 * implements hook_theme_registry_alter
 */ 
function MODULE_theme_registry_alter(&$theme_registry){
  
  // set our custom fxn for theming form_element_label
  
  // duplicate the original theme hook, under a new.  This
  // will allow us to 'wrap' the theme function without
  // breaking it.
  $theme_registry['form_element_label_x'] = 
      $theme_registry['form_element_label'];

  // because we don't need to count on drupal to re-load this file
  // we're going to only override the 'function' key
  // and leave the 'theme path' etc. to the core setting
  // to avoid potential problems down the road if
  // core changes.
  $theme_registry['form_element_label']['function'] = 
      'MODULE_theme_form_element_label';
  
}

2. Implement the custom theme function:

function MODULE_theme_form_element_label($variables){

  // Print description at top of field outpout. Because of jujitsu happening in
  // fapi theming/wrapping, ctools and WYSIWYG, we have to jump through some hoops 
  // to pull this off.
  $desc = '';
  if( isset( $variables['element']['#theme_options']['description at top'] ) ){
    $desc = '<div class="description">' . $variables['element']['#theme_options']['description at top'] . '</div>';
  }

  // pass element through the "real" theme hook
  return theme( 'form_element_label_x' , $variables) . $desc;
}

3. Modify field declarations in $form:


  /**
   * In MODULE_admin_form()
  **/

  // ...

  $form[ 'MY_TEXTAREA_FIELD' ] = array(

    // ... other FAPI keys, as per normal ...

    // Print description at top of field outpout, using some non-standard
    // theming jujitsu.  Specifially, we're defining a custom FAPI
    // key, '#theme_options', which will get picked up in our custom
    // theming function.
    // see MODULE_theme_form_element_label.
    '#theme_options' => array(
      'description at top' => 'HERE IS OUR DESCRIPTION!' ,
    ) ,

  // ...

All it took was a couple lost days, and, Voila!, I've made this very minor UX improvement. Good thing for my client I'm not billing by the hour.

Comments

Sheldon Rampton’s picture

Thanks for this writeup. I used it as the basis to create a Drupal module that repositions the #description text on all format-enabled textarea form fields:

http://drupal.org/sandbox/sheldon/1812408

At present the module is pretty much a straight copy-and-paste of your code, plus a hook_form_alter() to apply the transformation to all textarea fields on all forms. It would probably be nice to add some options such as the ability to include/exclude individual forms and/or fields.

----------------
Customer Support Engineer, Granicus
https://granicus.com

jrb’s picture

I've added a sandbox module to do this in D7 without adding a new element.

https://drupal.org/sandbox/jrb/top_description

It should work for all fields including WYSIWYG text areas and multiple fields.

kaare’s picture

I'm working on a new, more complete solution for this problem in D7:

Form element layout

It's an Form API extension with support for fields. It doesn't alter the layout unless explicitly told so. And while working on it I came across jrb's version, but realized I had severely passed it feature-wise, so I'm continuing working on this.

odegard’s picture

I just released a module doing some of this, as well as other things. Didn't see your sandbox until now...

Check it ouy: https://www.drupal.org/project/better_field_descriptions

This module allows "description makers" to edit descriptions on a different page than the content type field management page. The descriptions are themeable and can be put over or under the field itself.

Sheldon Rampton’s picture

I released my "label help" module awhile ago, and it seems to have become fairly popular:

https://www.drupal.org/project/label_help

I haven't looked through your code to see how it differs from mine. Rather than have two separate modules that try to solve this problem, do you think there's a way we could combine efforts?

----------------
Customer Support Engineer, Granicus
https://granicus.com

odegard’s picture

Hi,

the reason I made my module was actually to solve a different problem, enabling users to add descriptions to form fields without them having to mess around in the content type field management pages.

Since the description field itself is rather limited in what tags you're allowed to use, I settled on using the #prefix or #suffix to the fields for the rendering of the descriptions. My customers have some times very long descriptions so I need to wrap them in fieldsets, this won't work with the normal description.

So, I didn't set out to replace your or any others' module. I didn't find it when searching for existing solutions since my use case was more "have users create descriptions" and not "placing descriptions on the form". The second part was a natural consequence of the first part. I can't do the first part without also providing a mechanism to place the new descriptions...

Until an hour ago my module didn't really allow you to put the new description between the title and the field. It was either all above or all below (like #prefix and #suffix works). I have now added a third option to place it between the title and field, it was only a few new lines.

I'm sure there is a more proper way of solving this though. What I do is set my custom description label to the fields #title, display the legend on top and then set the original #title to #title_display => 'invisible'. It's very easy, does not change anything permanently and works fine for all fields as far as I can tell.

I would love to hear from you if you think there is something we can work together on.

kaare’s picture

This is now a full project ready for mainstream usage (and testing).

Form element layout: https://www.drupal.org/project/fel

stefan.butura’s picture

This is how I managed to get this to work in Drupal 8:

/**
 * Implements hook_preprocess_HOOK() for text_format_wrapper.
 */
function HOOK_preprocess_text_format_wrapper(&$variables) {
  // Unset the description since we render this in form-element.html.twig.
  if (!empty($variables['description'])) {
    unset($variables['description']);
  }
}

/**
 * Implements hook_element_info_alter().
 */
function HOOK_element_info_alter(array &$info) {
  // TextFormat::processFormat removes the description for text_format elements.
  // We need to temporarily save it in another place using leap_base_process_text_format_before
  // and then put it back in the #description key.
  array_unshift($info['text_format']['#process'], 'HOOK_process_text_format_before');
  $info['text_format']['#process'][] = 'HOOK_process_text_format_after';
}

/**
 * #process function for text_format elements used to temporarily save the element description.
 */
function HOOK_process_text_format_before(&$element, FormStateInterface $form_state, &$complete_form) {
  if (!empty($element['#description'])) {
    $element['#temp_description'] = $element['#description'];
  }
  return $element;
}

/**
 * #process function for text_format elements used to put back the #description.
 */
function HOOK_process_text_format_after(&$element, FormStateInterface $form_state, &$complete_form) {
  if (!empty($element['#temp_description']) && !empty($element['value'])) {
    $element['value']['#description'] = $element['#temp_description'];
  }
  return $element;
}

/**
 * Implements hook_preprocess_HOOK() for form_element.
 */
function HOOK_preprocess_form_element(&$variables) {
  if ($variables['type'] == 'textarea') {
    $variables['description_display'] = 'before';
  }
}