diff --git a/core/includes/form.inc b/core/includes/form.inc
index 63f1de9..7280b4c 100644
--- a/core/includes/form.inc
+++ b/core/includes/form.inc
@@ -1869,6 +1869,7 @@ function form_builder($form_id, &$element, &$form_state) {
'#required' => FALSE,
'#attributes' => array(),
'#title_display' => 'before',
+ '#description_display' => 'after',
);
// Special handling if we're on the top level form element.
@@ -4668,19 +4669,23 @@ function form_process_file($element) {
* $form structure and set via form_builder()).
* - form-disabled: Only set if the form element is #disabled.
*
- * In addition to the element itself, the DIV contains a label for the element
- * based on the optional #title_display property, and an optional #description.
+ * In addition to the element itself, the DIV contains the element's label
+ * consisting of the #title property and an optional #required marker. It also
+ * contains an optional #description. While by default, the label is displayed
+ * before the element and the description right after the element, this can be
+ * modified by the optional #title_display and #description_display properties.
*
* The optional #title_display property can have these values:
- * - before: The label is output before the element. This is the default.
- * The label includes the #title and the required marker, if #required.
- * - after: The label is output after the element. For example, this is used
+ * - before (default): The label is displayed before the element. The label
+ * includes the #title and the required marker, if #required.
+ * - after: The label is displayed after the element. For example, this is used
* for radio and checkbox #type elements as set in system_element_info().
* If the #title is empty but the field is #required, the label will
* contain only the required marker.
- * - invisible: Labels are critical for screen readers to enable them to
- * properly navigate through forms but can be visually distracting. This
- * property hides the label for everyone except screen readers.
+ * - invisible: While the label isn't displayed by visual browsers, it is
+ * available to screen readers which need it for properly navigating through
+ * forms. It is also available to validation functions that might set an error
+ * on the field that failed validation.
* - attribute: Set the title attribute on the element to create a tooltip
* but output no label element. This is supported only for checkboxes
* and radios in form_pre_render_conditional_form_element(). It is used
@@ -4693,13 +4698,25 @@ function form_process_file($element) {
* This can be useful in cases such as the password_confirm element, which
* creates children elements that have their own labels and required markers,
* but the parent element should have neither. Use this carefully because a
- * field without an associated label can cause accessibility challenges.
+ * field without an associated label can cause accessibility challenges. In most
+ * cases where displaying a title would be visually distracting, it is strongly
+ * recommended to supply a title but render it invisible or as attribute.
+ *
+ * The optional #description_display property can have these values:
+ * - before: The description is output before the element. This may be used for
+ * elements that occupy very much vertical screen space.
+ * - after (default): The description is output after the element.
+ * - invisible: While the description isn't displayed by visual browsers, it is
+ * available to screen readers.
+ *
+ * If the #description property is not set, the description will not be output,
+ * regardless of the #description_display value.
*
* @param $variables
* An associative array containing:
* - element: An associative array containing the properties of the element.
- * Properties used: #title, #title_display, #description, #id, #required,
- * #children, #type, #name.
+ * Properties used: #title, #title_display, #description,
+ * #description_display, #id, #required, #children, #type, #name.
*
* @ingroup themeable
*/
@@ -4710,6 +4727,7 @@ function theme_form_element($variables) {
// may not necessarily have been processed by form_builder().
$element += array(
'#title_display' => 'before',
+ '#description_display' => 'after',
);
// Take over any #wrapper_attributes defined by the element.
@@ -4734,43 +4752,45 @@ function theme_form_element($variables) {
if (!empty($element['#attributes']['disabled'])) {
$attributes['class'][] = 'form-disabled';
}
- $output = '
' . "\n";
+ $output = "\n" . '
' . "\n";
- // If #title is not set, we don't display any label or required marker.
+ // If #title or #description is not set at all, unset the respective display
+ // property, so the theme layer isn't unnecessarily called.
if (!isset($element['#title'])) {
$element['#title_display'] = 'none';
}
- $prefix = isset($element['#field_prefix']) ? '' . $element['#field_prefix'] . ' ' : '';
- $suffix = isset($element['#field_suffix']) ? ' ' . $element['#field_suffix'] . '' : '';
-
- switch ($element['#title_display']) {
- case 'before':
- case 'invisible':
- $output .= ' ' . theme('form_element_label', $variables);
- $output .= ' ' . $prefix . $element['#children'] . $suffix . "\n";
- break;
+ if (!isset($element['#description'])) {
+ $element['#description_display'] == 'none';
+ }
- case 'after':
- $output .= ' ' . $prefix . $element['#children'] . $suffix;
- $output .= ' ' . theme('form_element_label', $variables) . "\n";
- break;
+ // Print #title and #decription if it should be displayed before the element.
+ if ($element['#title_display'] == 'before' || $element['#title_display'] == 'invisible') {
+ $output .= theme('form_element_label', $variables);
+ }
+ if ($element['#description_display'] == 'before') {
+ $output .= theme('form_element_description', $variables);
+ }
- case 'none':
- case 'attribute':
- // Output no label and no required marker, only the children.
- $output .= ' ' . $prefix . $element['#children'] . $suffix . "\n";
- break;
+ // Prepend a field prefix if given.
+ if (isset($element['#field_prefix'])) {
+ $output .= ' ' . $element['#field_prefix'] . '' . "\n";
+ }
+ // Render the element's children.
+ $output .= ' ' . $element['#children'] . "\n";
+ // Append a field suffix if given.
+ if (isset($element['#field_suffix'])) {
+ $output .= ' ' . $element['#field_suffix'] . '' . "\n";
}
- if (!empty($element['#description'])) {
- $attributes = array('class' => 'description');
- if (!empty($element['#id'])) {
- $attributes['id'] = $element['#id'] . '--description';
- }
- $output .= '
' . $element['#description'] . "
\n";
+ // Print #title and #decription if it should be displayed after the element.
+ if ($element['#title_display'] == 'after') {
+ $output .= theme('form_element_label', $variables);
+ }
+ if ($element['#description_display'] == 'after' || $element['#description_display'] == 'invisible') {
+ $output .= theme('form_element_description', $variables);
}
- $output .= "
\n";
+ $output .= '
' . "\n";
return $output;
}
@@ -4811,7 +4831,7 @@ function theme_form_required_marker($variables) {
* @param $variables
* An associative array containing:
* - element: An associative array containing the properties of the element.
- * Properties used: #required, #title, #id, #value, #description.
+ * Properties used: #id, #title, #title_display, #required.
*
* @ingroup themeable
*/
@@ -4842,7 +4862,46 @@ function theme_form_element_label($variables) {
$attributes['for'] = $element['#id'];
}
- return '';
+ return ' ' . "\n";
+}
+
+/**
+ * Prepares variables for form element description templates.
+ *
+ * Default template: form-element-description.html.twig
+ *
+ * The description is associated with the element itself by the element #id.
+ * While by default displayed after the form element, the #description_display
+ * property may specify it to be displayed before the form element or to be
+ * visually hidden.
+ * For elements with no #description, this function will not be called.
+ *
+ * @param $variables
+ * An associative array containing:
+ * - element: An associative array containing the properties of the element.
+ * Properties used: #id, #description, #description_display.
+ */
+function template_preprocess_form_element_description(&$variables) {
+ $element = $variables['element'];
+
+ // If description is empty, don't output anything.
+ if (!isset($element['#description']) || $element['#description'] === '') {
+ $variables['description'] = $variables['attributes'] = NULL;
+ }
+ else {
+ $attributes = array();
+ if ($element['#description_display'] == 'invisible') {
+ $attributes['class'] = 'visually-hidden';
+ }
+ else {
+ $attributes['class'] = 'description';
+ }
+ if (!empty($element['#id'])) {
+ $attributes['id'] = $element['#id'] . '--description';
+ }
+ $variables['attributes'] = $attributes;
+ $variables['description'] = $element['#description'];
+ }
}
/**
diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index e49bb76..c443493 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -3157,6 +3157,10 @@ function drupal_common_theme() {
'form_element_label' => array(
'render element' => 'element',
),
+ 'form_element_description' => array(
+ 'render element' => 'element',
+ 'template' => 'form-element-description',
+ ),
'vertical_tabs' => array(
'render element' => 'element',
),
diff --git a/core/modules/system/lib/Drupal/system/Tests/Form/ElementsLabelsTest.php b/core/modules/system/lib/Drupal/system/Tests/Form/ElementsLabelsTest.php
index feb354b..8eb3578 100644
--- a/core/modules/system/lib/Drupal/system/Tests/Form/ElementsLabelsTest.php
+++ b/core/modules/system/lib/Drupal/system/Tests/Form/ElementsLabelsTest.php
@@ -23,8 +23,8 @@ class ElementsLabelsTest extends WebTestBase {
public static function getInfo() {
return array(
- 'name' => 'Form element and label output test',
- 'description' => 'Test form element labels, required markers and associated output.',
+ 'name' => 'Form element label and description test',
+ 'description' => 'Test form element labels, required markers, descriptions and associated output.',
'group' => 'Form API',
);
}
@@ -97,4 +97,30 @@ function testFormLabels() {
$elements = $this->xpath('//div[@id="edit-form-radios-title-attribute"]');
$this->assertEqual($elements[0]['title'], 'Radios test' . ' (' . t('Required') . ')', 'Title attribute found.');
}
+
+ /**
+ * Test description of form elements with different placement options.
+ */
+ function testFormDescriptions() {
+ $test = $this->drupalGet('form_test/form-descriptions');
+
+ // Check #description placement with #description_display='after'.
+ $field_id = 'edit-form-textfield-test-description-after';
+ $description_id = $field_id . '--description';
+ $elements = $this->xpath('//input[@id="' . $field_id . '" and @aria-describedby="' . $description_id . '"]/following-sibling::div[@id="' . $description_id . '"]');
+ $this->assertTrue(isset($elements[0]), t('Properly places the #description element after the form item.'));
+
+ // Check #description placement with #description_display='before'.
+ $field_id = 'edit-form-textfield-test-description-before';
+ $description_id = $field_id . '--description';
+ $elements = $this->xpath('//input[@id="' . $field_id . '" and @aria-describedby="' . $description_id . '"]/preceding-sibling::div[@id="' . $description_id . '"]');
+ $this->assertTrue(isset($elements[0]), t('Properly places the #description element before the form item.'));
+
+ // Check #description placement with #description_display='invisible'.
+ $field_id = 'edit-form-textfield-test-description-invisible';
+ $description_id = $field_id . '--description';
+ $elements = $this->xpath('//input[@id="' . $field_id . '" and @aria-describedby="' . $description_id . '"]/following-sibling::div[@class="visually-hidden"]');
+ $this->assertTrue(isset($elements[0]), t('Properly renders the #description element visually-hidden.'));
+ }
+
}
diff --git a/core/modules/system/system.api.php b/core/modules/system/system.api.php
index 0d9b5be..dbc94fa 100644
--- a/core/modules/system/system.api.php
+++ b/core/modules/system/system.api.php
@@ -235,6 +235,8 @@ function hook_queue_info_alter(&$queues) {
* - "#submit": array of callback functions taking $form and $form_state.
* - "#title_display": optional string indicating if and how #title should be
* displayed, see theme_form_element() and theme_form_element_label().
+ * - "#description_display": optional string indicating how the #description
+ * should be displayed, see theme_form_element().
*
* @see hook_element_info_alter()
* @see system_element_info()
diff --git a/core/modules/system/tests/modules/form_test/form_test.module b/core/modules/system/tests/modules/form_test/form_test.module
index a3d1cd0..1b1abde 100644
--- a/core/modules/system/tests/modules/form_test/form_test.module
+++ b/core/modules/system/tests/modules/form_test/form_test.module
@@ -743,6 +743,34 @@ function form_label_test_form() {
}
/**
+ * A form for testing placement of form descriptions.
+ */
+function form_description_test_form() {
+ $form['form_textfield_test_description_before'] = array(
+ '#type' =>'textfield',
+ '#title' => t('Textfield test for description before element'),
+ '#description' => t('Textfield test for description before element'),
+ '#description_display' => 'before'
+ );
+
+ $form['form_textfield_test_description_after'] = array(
+ '#type' =>'textfield',
+ '#title' => t('Textfield test for description after element'),
+ '#description' => t('Textfield test for description after element'),
+ '#description_display' => 'after'
+ );
+
+ $form['form_textfield_test_description_invisible'] = array(
+ '#type' =>'textfield',
+ '#title' => t('Textfield test for visually-hidden description'),
+ '#description' => t('Textfield test for visually-hidden description'),
+ '#description_display' => 'invisible'
+ );
+
+ return $form;
+}
+
+/**
* Menu callback; Invokes a form builder function with a wrapper callback.
*
* @deprecated \Drupal\form_test\Controller\FormTestController::wrapperCallback()
diff --git a/core/modules/system/templates/form-element-description.html.twig b/core/modules/system/templates/form-element-description.html.twig
new file mode 100644
index 0000000..d35f253
--- /dev/null
+++ b/core/modules/system/templates/form-element-description.html.twig
@@ -0,0 +1,22 @@
+{#
+/**
+ * @file
+ * Default theme implementation for a form element description.
+ *
+ * This will not be called if the description is empty.
+ *
+ * Available variables:
+ * - description: The text of the description.
+ * - attributes: A list of HTML attributes for the description.
+ *
+ * @see template_preprocess()
+ * @see template_preprocess_form_description()
+ *
+ * @ingroup themeable
+ */
+#}
+{% if (description is not empty) %}
+