diff --git a/core/includes/form.inc b/core/includes/form.inc index bd256ad..561e042 100644 --- a/core/includes/form.inc +++ b/core/includes/form.inc @@ -18,14 +18,15 @@ * Default template: select.html.twig. * * It is possible to group options together; to do this, change the format of - * $options to an associative array in which the keys are group labels, and the - * values are associative arrays in the normal $options format. + * the #options property to an associative array in which the keys are group + * labels, and the values are associative arrays in the normal #options format. * * @param $variables * An associative array containing: * - element: An associative array containing the properties of the element. * Properties used: #title, #value, #options, #description, #extra, - * #multiple, #required, #name, #attributes, #size. + * #multiple, #required, #name, #attributes, #size, #sort_options, + * #sort_start. */ function template_preprocess_select(&$variables) { $element = $variables['element']; @@ -37,7 +38,7 @@ function template_preprocess_select(&$variables) { } /** - * Converts an options form element into a structured array for output. + * Converts the options in a select element into a structured array for output. * * This function calls itself recursively to obtain the values for each optgroup * within the list of options and when the function encounters an object with @@ -63,6 +64,12 @@ function template_preprocess_select(&$variables) { * object with an 'option' property, the key is ignored, the contents of * the option property are interpreted as $element['#options'], and the * resulting HTML is added to the output. + * - #sort_options: (optional) If set to TRUE (default is FALSE), sort the + * options by their labels. Can be set within an option group to sort that + * group. + * - #sort_start: (optional) Option index to start sorting at, where 0 is the + * first option (default is 0, and this only applies if #sort_options is + * TRUE). Can be used within an option group. * - #value: Optional integer, string, or array representing which option(s) * to pre-select when the list is first displayed. The integer or string * must match the key of an option in the '#options' list. If '#multiple' is @@ -90,7 +97,17 @@ function form_select_options($element, $choices = NULL) { return []; } $choices = $element['#options']; + $sort_options = isset($element['#sort_options']) && $element['#sort_options']; + $sort_start = (isset($element['#sort_start']) ? $element['#sort_start'] : 0); } + else { + // We are within an option group. + $sort_options = isset($choices['#sort_options']) && $choices['#sort_options']; + $sort_start = (isset($choices['#sort_start']) ? $choices['#sort_start'] : 0); + unset($choices['#sort_options']); + unset($choices['#sort_start']); + } + // array_key_exists() accommodates the rare event where $element['#value'] is NULL. // isset() fails in this situation. $value_valid = isset($element['#value']) || array_key_exists('#value', $element); @@ -125,6 +142,16 @@ function form_select_options($element, $choices = NULL) { $options[] = $option; } } + if ($sort_options) { + $unsorted = array_slice($options, 0, $sort_start); + $sorted = array_slice($options, $sort_start); + uasort($sorted, function ($a, $b) { + $label1 = (string) $a['label']; + $label2 = (string) $b['label']; + return strcmp($label1, $label2); + }); + $options = array_merge($unsorted, $sorted); + } return $options; } diff --git a/core/lib/Drupal/Core/Render/Element/Select.php b/core/lib/Drupal/Core/Render/Element/Select.php index 300c244..908ea43 100644 --- a/core/lib/Drupal/Core/Render/Element/Select.php +++ b/core/lib/Drupal/Core/Render/Element/Select.php @@ -14,6 +14,12 @@ * list. If a value is an array, it will be rendered similarly, but as an * optgroup. The key of the sub-array will be used as the label for the * optgroup. Nesting optgroups is not allowed. + * - #sort_options: (optional) If set to TRUE (default is FALSE), sort the + * options by their labels, after rendering and translation is complete. + * Can be set within an option group to sort that group. + * - #sort_start: (optional) Option index to start sorting at, where 0 is the + * first option (default is 0, and this only applies if #sort_options is + * TRUE). Can be used within an option group. * - #empty_option: (optional) The label to show for the first default option. * By default, the label is automatically set to "- Select -" for a required * field and "- None -" for an optional field. @@ -68,6 +74,8 @@ public function getInfo() { return [ '#input' => TRUE, '#multiple' => FALSE, + '#sort_options' => FALSE, + '#sort_start' => 0, '#process' => [ [$class, 'processSelect'], [$class, 'processAjaxForm'], diff --git a/core/modules/system/tests/modules/form_test/src/Form/FormTestSelectForm.php b/core/modules/system/tests/modules/form_test/src/Form/FormTestSelectForm.php index d1b14d8..9f2d7e6 100644 --- a/core/modules/system/tests/modules/form_test/src/Form/FormTestSelectForm.php +++ b/core/modules/system/tests/modules/form_test/src/Form/FormTestSelectForm.php @@ -4,6 +4,7 @@ use Drupal\Core\Form\FormBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\StringTranslation\TranslatableMarkup; use Symfony\Component\HttpFoundation\JsonResponse; /** @@ -125,6 +126,61 @@ public function buildForm(array $form, FormStateInterface $form_state) { ], ]; + $unsorted_options = [ + // Don't use $this->t() here, because we really don't want these + // to be translated or added to localize.d.o. Using TranslatableMarkup + // in places tests that casting to string is working, however. + 'uso_none' => new TranslatableMarkup('- none -'), + 'uso_second' => new TranslatableMarkup('second element'), + 'uso_zzgroup' => [ + 'uso_gc' => new TranslatableMarkup('group c'), + 'uso_ga' => new TranslatableMarkup('group a'), + 'uso_gb' => 'group b', + ], + 'uso_yygroup' => [ + 'uso_ge' => new TranslatableMarkup('group e'), + 'uso_gd' => new TranslatableMarkup('group d'), + 'uso_gf' => new TranslatableMarkup('group f'), + ], + 'uso_d' => 'd', + 'uso_c' => new TranslatableMarkup('main c'), + 'uso_b' => new TranslatableMarkup('main b'), + 'uso_a' => 'a', + ]; + + $form['unsorted'] = [ + '#type' => 'select', + '#options' => $unsorted_options, + ]; + + // For the sorted select element, replace 'uso' in the keys with 'sso' so + // keys are all unique between the sorted and unsorted select, for testing. + $sortable_options = []; + foreach ($unsorted_options as $key => $item) { + $key = str_replace('uso', 'sso', $key); + if (is_array($item)) { + $new_item = []; + foreach ($item as $subkey => $subitem) { + $subkey = str_replace('uso', 'sso', $subkey); + $new_item[$subkey] = $subitem; + } + $sortable_options[$key] = $new_item; + } + else { + $sortable_options[$key] = $item; + } + } + + // Make the first group sorted, the second group unsorted, and the + // sorting on the main list start at element 3. + $sortable_options['sso_zzgroup']['#sort_options'] = TRUE; + $form['sorted'] = [ + '#type' => 'select', + '#options' => $sortable_options, + '#sort_options' => TRUE, + '#sort_start' => 3, + ]; + $form['submit'] = ['#type' => 'submit', '#value' => 'Submit']; return $form; } diff --git a/core/modules/system/tests/src/Functional/Form/FormTest.php b/core/modules/system/tests/src/Functional/Form/FormTest.php index 849944b..6156119 100644 --- a/core/modules/system/tests/src/Functional/Form/FormTest.php +++ b/core/modules/system/tests/src/Functional/Form/FormTest.php @@ -464,6 +464,58 @@ public function testEmptySelect() { } /** + * Tests sorting and not sorting of options in a select element. + */ + public function testSelectSorting() { + $this->drupalGet('form-test/select'); + $content = $this->getSession()->getPage()->getContent(); + + // Verify the expected order of the options in the unsorted and + // sorted elements. + $expected_order = [ + 'uso_none', + 'uso_second', + 'uso_zzgroup', + 'uso_gc', + 'uso_ga', + 'uso_gb', + 'uso_yygroup', + 'uso_ge', + 'uso_gd', + 'uso_gf', + 'uso_d', + 'uso_c', + 'uso_b', + 'uso_a', + 'sso_none', + 'sso_second', + 'sso_zzgroup', + 'sso_ga', + 'sso_gb', + 'sso_gc', + 'sso_a', + 'sso_d', + 'sso_b', + 'sso_c', + 'sso_yygroup', + 'sso_ge', + 'sso_gd', + 'sso_gf', + ]; + + $prev_position = 0; + foreach ($expected_order as $string) { + $position = strpos($content, $string); + $this->assertTrue($position > $prev_position, 'Item ' . $string . ' is in correct order'); + $prev_position = $position; + } + + // Verify that #sort_order and #sort_start are not in the page. + $this->assertFalse(strpos($content, '#sort_order'), 'Sort order property has been removed'); + $this->assertFalse(strpos($content, '#sort_start'), 'Sort start property has been removed'); + } + + /** * Tests validation of #type 'number' and 'range' elements. */ public function testNumber() {