diff --git a/core/includes/form.inc b/core/includes/form.inc index e4f7982..b20d33a 100644 --- a/core/includes/form.inc +++ b/core/includes/form.inc @@ -2695,19 +2695,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 @@ -2720,13 +2724,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 */ @@ -2737,6 +2753,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. @@ -2761,43 +2778,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; } @@ -2838,7 +2857,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 */ @@ -2869,7 +2888,46 @@ function theme_form_element_label($variables) { $attributes['for'] = $element['#id']; } - return '' . t('!title!required', array('!title' => $title, '!required' => $required)) . ''; + return ' ' . t('!title!required', array('!title' => $title, '!required' => $required)) . '' . "\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 d14f7f8..fe97171 100644 --- a/core/includes/theme.inc +++ b/core/includes/theme.inc @@ -2731,6 +2731,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/lib/Drupal/Core/Form/FormBuilder.php b/core/lib/Drupal/Core/Form/FormBuilder.php index 43d15a6..d41821d 100644 --- a/core/lib/Drupal/Core/Form/FormBuilder.php +++ b/core/lib/Drupal/Core/Form/FormBuilder.php @@ -1312,6 +1312,7 @@ public function doBuildForm($form_id, &$element, &$form_state) { '#required' => FALSE, '#attributes' => array(), '#title_display' => 'before', + '#description_display' => 'after', '#errors' => NULL, ); 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 6c51a04..6519ac0 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 1de6b15..136ece3 100644 --- a/core/modules/system/system.api.php +++ b/core/modules/system/system.api.php @@ -234,6 +234,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/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) %} + + {{ description }} + +{% endif %} 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 922bc78..3838662 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/tests/modules/form_test/form_test.routing.yml b/core/modules/system/tests/modules/form_test/form_test.routing.yml index a37c9d8..1356bb1 100644 --- a/core/modules/system/tests/modules/form_test/form_test.routing.yml +++ b/core/modules/system/tests/modules/form_test/form_test.routing.yml @@ -342,6 +342,14 @@ form_test.label: requirements: _access: 'TRUE' +form_test.description: + path: '/form_test/form-descriptions' + defaults: + _content: '\Drupal\form_test\Form\FormTestForm::testDescription' + _title: 'Form description test' + requirements: + _access: 'TRUE' + form_test.state_persistence: path: '/form-test/state-persist' defaults: diff --git a/core/modules/system/tests/modules/form_test/lib/Drupal/form_test/Form/FormTestForm.php b/core/modules/system/tests/modules/form_test/lib/Drupal/form_test/Form/FormTestForm.php index 6fa4ab0..a3fd42b 100644 --- a/core/modules/system/tests/modules/form_test/lib/Drupal/form_test/Form/FormTestForm.php +++ b/core/modules/system/tests/modules/form_test/lib/Drupal/form_test/Form/FormTestForm.php @@ -319,6 +319,15 @@ public function testLabel() { } /** + * Wraps form_description_test_form(). + * + * @todo Remove form_description_test_form(). + */ + public function testDescription() { + return drupal_get_form('form_description_test_form'); + } + + /** * Wraps form_test_state_persist(). * * @todo Remove form_test_state_persist().