diff --git a/grade/classes/output/export_action_bar.php b/grade/classes/output/export_action_bar.php
index 8186ad45cd4..da513904001 100644
--- a/grade/classes/output/export_action_bar.php
+++ b/grade/classes/output/export_action_bar.php
@@ -27,9 +27,6 @@ use moodle_url;
*/
class export_action_bar extends action_bar {
- /** @var moodle_url $exportactiveurl The URL that should be set as active in the exports URL selector element. */
- protected $exportactiveurl;
-
/** @var string $activeplugin The plugin of the current export grades page (xml, ods, ...). */
protected $activeplugin;
@@ -37,12 +34,14 @@ class export_action_bar extends action_bar {
* The class constructor.
*
* @param \context $context The context object.
- * @param moodle_url $exportactiveurl The URL that should be set as active in the exports URL selector element.
+ * @param null $unused This parameter has been deprecated since 4.1 and should not be used anymore.
* @param string $activeplugin The plugin of the current export grades page (xml, ods, ...).
*/
- public function __construct(\context $context, moodle_url $exportactiveurl, string $activeplugin) {
+ public function __construct(\context $context, $unused, string $activeplugin) {
+ if ($unused !== null) {
+ debugging('Deprecated argument passed to ' . __FUNCTION__, DEBUG_DEVELOPER);
+ }
parent::__construct($context);
- $this->exportactiveurl = $exportactiveurl;
$this->activeplugin = $activeplugin;
}
@@ -85,14 +84,18 @@ class export_action_bar extends action_bar {
}
$exportsmenu = [];
+ $exportactiveurl = null;
// Generate the data for the exports navigation selector menu.
foreach ($exports as $export) {
$exportsmenu[$export->link->out()] = $export->string;
+ if ($export->id == $this->activeplugin) {
+ $exportactiveurl = $export->link->out();
+ }
}
// This navigation selector menu will contain the links to all available grade export plugin pages.
- $exportsurlselect = new \url_select($exportsmenu, $this->exportactiveurl->out(false), null,
- 'gradesexportactionselect');
+ $exportsurlselect = new \core\output\select_menu('exportas', $exportsmenu, $exportactiveurl);
+ $exportsurlselect->set_label(get_string('exportas', 'grades'));
$data['exportselector'] = $exportsurlselect->export_for_template($output);
return $data;
diff --git a/grade/classes/output/export_key_manager_action_bar.php b/grade/classes/output/export_key_manager_action_bar.php
index b3214934bb5..0b1268c990d 100644
--- a/grade/classes/output/export_key_manager_action_bar.php
+++ b/grade/classes/output/export_key_manager_action_bar.php
@@ -48,8 +48,7 @@ class export_key_manager_action_bar extends action_bar {
}
$courseid = $this->context->instanceid;
// Get the data used to output the general navigation selector and exports navigation selector.
- $exportnavselectors = new export_action_bar($this->context,
- new moodle_url('/grade/export/keymanager.php', ['id' => $courseid]), 'keymanager');
+ $exportnavselectors = new export_action_bar($this->context, null, 'keymanager');
$data = $exportnavselectors->export_for_template($output);
// Add a button to the action bar with a link to the 'add user key' page.
diff --git a/grade/classes/output/import_action_bar.php b/grade/classes/output/import_action_bar.php
index e856c9df56a..f96bb57ee1d 100644
--- a/grade/classes/output/import_action_bar.php
+++ b/grade/classes/output/import_action_bar.php
@@ -27,9 +27,6 @@ use moodle_url;
*/
class import_action_bar extends action_bar {
- /** @var moodle_url $importactiveurl The URL that should be set as active in the imports URL selector element. */
- protected $importactiveurl;
-
/** @var string $activeplugin The plugin of the current import grades page (xml, csv, ...). */
protected $activeplugin;
@@ -37,12 +34,14 @@ class import_action_bar extends action_bar {
* The class constructor.
*
* @param \context $context The context object.
- * @param moodle_url $importactiveurl The URL that should be set as active in the imports URL selector element.
+ * @param null $unused This parameter has been deprecated since 4.1 and should not be used anymore.
* @param string $activeplugin The plugin of the current import grades page (xml, csv, ...).
*/
- public function __construct(\context $context, moodle_url $importactiveurl, string $activeplugin) {
+ public function __construct(\context $context, $unused, string $activeplugin) {
+ if ($unused !== null) {
+ debugging('Deprecated argument passed to ' . __FUNCTION__, DEBUG_DEVELOPER);
+ }
parent::__construct($context);
- $this->importactiveurl = $importactiveurl;
$this->activeplugin = $activeplugin;
}
@@ -85,14 +84,18 @@ class import_action_bar extends action_bar {
}
$importsmenu = [];
+ $importactiveurl = null;
// Generate the data for the imports navigation selector menu.
foreach ($imports as $import) {
$importsmenu[$import->link->out()] = $import->string;
+ if ($import->id == $this->activeplugin) {
+ $importactiveurl = $import->link->out();
+ }
}
// This navigation selector menu will contain the links to all available grade export plugin pages.
- $importsurlselect = new \url_select($importsmenu, $this->importactiveurl->out(false), null,
- 'gradesimportactionselect');
+ $importsurlselect = new \core\output\select_menu('importas', $importsmenu, $importactiveurl);
+ $importsurlselect->set_label(get_string('importas', 'grades'));
$data['importselector'] = $importsurlselect->export_for_template($output);
return $data;
diff --git a/grade/classes/output/import_key_manager_action_bar.php b/grade/classes/output/import_key_manager_action_bar.php
index f4ba20c54a1..de421c113d1 100644
--- a/grade/classes/output/import_key_manager_action_bar.php
+++ b/grade/classes/output/import_key_manager_action_bar.php
@@ -48,8 +48,7 @@ class import_key_manager_action_bar extends action_bar {
}
$courseid = $this->context->instanceid;
// Get the data used to output the general navigation selector and imports navigation selector.
- $importnavselectors = new import_action_bar($this->context,
- new moodle_url('/grade/import/keymanager.php', ['id' => $courseid]), 'keymanager');
+ $importnavselectors = new import_action_bar($this->context, null, 'keymanager');
$data = $importnavselectors->export_for_template($output);
// Add a button to the action bar with a link to the 'add user key' page.
diff --git a/grade/export/ods/index.php b/grade/export/ods/index.php
index 69dc1c6bb35..f3ddf8dee1c 100644
--- a/grade/export/ods/index.php
+++ b/grade/export/ods/index.php
@@ -33,7 +33,7 @@ $context = context_course::instance($id);
require_capability('moodle/grade:export', $context);
require_capability('gradeexport/ods:view', $context);
-$actionbar = new \core_grades\output\export_action_bar($context, $PAGE->url, 'ods');
+$actionbar = new \core_grades\output\export_action_bar($context, null, 'ods');
print_grade_page_head($COURSE->id, 'export', 'ods',
get_string('exportto', 'grades') . ' ' . get_string('pluginname', 'gradeexport_ods'),
false, false, true, null, null, null, $actionbar);
diff --git a/grade/export/txt/index.php b/grade/export/txt/index.php
index c21eee12254..3c75d95827b 100644
--- a/grade/export/txt/index.php
+++ b/grade/export/txt/index.php
@@ -33,7 +33,7 @@ $context = context_course::instance($id);
require_capability('moodle/grade:export', $context);
require_capability('gradeexport/txt:view', $context);
-$actionbar = new \core_grades\output\export_action_bar($context, $PAGE->url, 'txt');
+$actionbar = new \core_grades\output\export_action_bar($context, null, 'txt');
print_grade_page_head($COURSE->id, 'export', 'txt',
get_string('exportto', 'grades') . ' ' . get_string('pluginname', 'gradeexport_txt'),
false, false, true, null, null, null, $actionbar);
diff --git a/grade/export/xls/index.php b/grade/export/xls/index.php
index 207a0a9bacb..b3f981718ca 100644
--- a/grade/export/xls/index.php
+++ b/grade/export/xls/index.php
@@ -33,7 +33,7 @@ $context = context_course::instance($id);
require_capability('moodle/grade:export', $context);
require_capability('gradeexport/xls:view', $context);
-$actionbar = new \core_grades\output\export_action_bar($context, $PAGE->url, 'xls');
+$actionbar = new \core_grades\output\export_action_bar($context, null, 'xls');
print_grade_page_head($COURSE->id, 'export', 'xls',
get_string('exportto', 'grades') . ' ' . get_string('pluginname', 'gradeexport_xls'),
false, false, true, null, null, null, $actionbar);
diff --git a/grade/export/xml/index.php b/grade/export/xml/index.php
index 8091807ff21..320ddf4218a 100644
--- a/grade/export/xml/index.php
+++ b/grade/export/xml/index.php
@@ -33,7 +33,7 @@ $context = context_course::instance($id);
require_capability('moodle/grade:export', $context);
require_capability('gradeexport/xml:view', $context);
-$actionbar = new \core_grades\output\export_action_bar($context, $PAGE->url, 'xml');
+$actionbar = new \core_grades\output\export_action_bar($context, null, 'xml');
print_grade_page_head($COURSE->id, 'export', 'xml',
get_string('exportto', 'grades') . ' ' . get_string('pluginname', 'gradeexport_xml'),
false, false, true, null, null, null, $actionbar);
diff --git a/grade/import/csv/index.php b/grade/import/csv/index.php
index 7b8513ec5a8..fe4fb921ffe 100644
--- a/grade/import/csv/index.php
+++ b/grade/import/csv/index.php
@@ -51,7 +51,7 @@ $separatemode = (groups_get_course_groupmode($COURSE) == SEPARATEGROUPS and
!has_capability('moodle/site:accessallgroups', $context));
$currentgroup = groups_get_course_group($course);
-$actionbar = new \core_grades\output\import_action_bar($context, $PAGE->url, 'csv');
+$actionbar = new \core_grades\output\import_action_bar($context, null, 'csv');
print_grade_page_head($course->id, 'import', 'csv', get_string('importcsv', 'grades'), false, false, true,
'importcsv', 'grades', null, $actionbar);
diff --git a/grade/import/direct/index.php b/grade/import/direct/index.php
index 1d61a18b736..c68b4dc5367 100644
--- a/grade/import/direct/index.php
+++ b/grade/import/direct/index.php
@@ -47,7 +47,7 @@ $separatemode = (groups_get_course_groupmode($COURSE) == SEPARATEGROUPS and
!has_capability('moodle/site:accessallgroups', $context));
$currentgroup = groups_get_course_group($course);
-$actionbar = new \core_grades\output\import_action_bar($context, $PAGE->url, 'direct');
+$actionbar = new \core_grades\output\import_action_bar($context, null, 'direct');
print_grade_page_head($course->id, 'import', 'direct', get_string('pluginname', 'gradeimport_direct'), false, false, true,
'userdata', 'gradeimport_direct', null, $actionbar);
diff --git a/grade/import/xml/index.php b/grade/import/xml/index.php
index 9fbb2143e35..3cf315e5a7a 100644
--- a/grade/import/xml/index.php
+++ b/grade/import/xml/index.php
@@ -87,7 +87,7 @@ if ($data = $mform->get_data()) {
}
}
-$actionbar = new \core_grades\output\import_action_bar($context, $PAGE->url, 'xml');
+$actionbar = new \core_grades\output\import_action_bar($context, null, 'xml');
print_grade_page_head($COURSE->id, 'import', 'xml', get_string('importxml', 'grades'),
false, false, true, 'importxml', 'gradeimport_xml', null, $actionbar);
diff --git a/grade/templates/export_action_bar.mustache b/grade/templates/export_action_bar.mustache
index 79b75ca45a7..9156d71b329 100644
--- a/grade/templates/export_action_bar.mustache
+++ b/grade/templates/export_action_bar.mustache
@@ -44,23 +44,25 @@
"title": null
},
"exportselector": {
- "id": "url_select56789",
- "action": "https://example.com/get",
- "formid": "gradesexportactionselect",
- "sesskey": "sesskey",
- "classes": "urlselect",
- "label": "",
- "helpicon": false,
- "showbutton": null,
+ "name": "exportas",
+ "value": "https://example.com/grade/export/ods/index.php",
+ "baseid": "select-menu56789",
+ "label": "Export as",
+ "labelattributes": [
+ {
+ "name": "class",
+ "value": "font-weight-bold"
+ }
+ ],
+ "selectedoption": "OpenDocument spreadsheet",
"options": [
{
"name": "OpenDocument spreadsheet",
- "value": "/grade/export/ods/index.php",
- "selected": true
+ "value": "https://example.com/grade/export/ods/index.php",
+ "selected": true,
+ "id": "select-menu-option56789"
}
- ],
- "disabled": false,
- "title": null
+ ]
}
}
}}
@@ -73,8 +75,13 @@
{{/generalnavselector}}
{{#exportselector}}
- {{>core/url_select}}
+ {{>core/select_menu}}
+ {{#js}}
+ document.querySelector('#{{baseid}}').addEventListener('change', function(e) {
+ window.location.href = e.target.value;
+ });
+ {{/js}}
{{/exportselector}}
diff --git a/grade/templates/import_action_bar.mustache b/grade/templates/import_action_bar.mustache
index 7a1334f1c42..423277017d0 100644
--- a/grade/templates/import_action_bar.mustache
+++ b/grade/templates/import_action_bar.mustache
@@ -44,23 +44,25 @@
"title": null
},
"importselector": {
- "id": "url_select56789",
- "action": "https://example.com/get",
- "formid": "gradesimportactionselect",
- "sesskey": "sesskey",
- "classes": "urlselect",
- "label": "",
- "helpicon": false,
- "showbutton": null,
+ "name": "importas",
+ "value": "https://example.com/grade/import/csv/index.php",
+ "baseid": "select-menu56789",
+ "label": "Import as",
+ "labelattributes": [
+ {
+ "name": "class",
+ "value": "font-weight-bold"
+ }
+ ],
+ "selectedoption": "CSV file",
"options": [
{
"name": "CSV file",
- "value": "/grade/import/csv/index.php",
- "selected": true
+ "value": "https://example.com/grade/import/csv/index.php",
+ "selected": true,
+ "id": "select-menu-option56789"
}
- ],
- "disabled": false,
- "title": null
+ ]
}
}
}}
@@ -73,8 +75,13 @@
{{/generalnavselector}}
{{#importselector}}
- {{>core/url_select}}
+ {{>core/select_menu}}
+ {{#js}}
+ document.querySelector('#{{baseid}}').addEventListener('change', function(e) {
+ window.location.href = e.target.value;
+ });
+ {{/js}}
{{/importselector}}
diff --git a/grade/tests/behat/behat_grade.php b/grade/tests/behat/behat_grade.php
index b44ea5562a6..87148ca0477 100644
--- a/grade/tests/behat/behat_grade.php
+++ b/grade/tests/behat/behat_grade.php
@@ -349,7 +349,7 @@ class behat_grade extends behat_base {
*/
public function i_navigate_to_import_page_in_the_course_gradebook($gradeimportoption) {
$this->i_navigate_to_in_the_course_gradebook("More > Import");
- $this->select_in_gradebook_navigation_selector($gradeimportoption, 'gradesimportactionselect');
+ $this->execute('behat_forms::i_set_the_field_to', [get_string('importas', 'grades'), $gradeimportoption]);
}
/**
@@ -364,7 +364,7 @@ class behat_grade extends behat_base {
*/
public function i_navigate_to_export_page_in_the_course_gradebook($gradeexportoption) {
$this->i_navigate_to_in_the_course_gradebook("More > Export");
- $this->select_in_gradebook_navigation_selector($gradeexportoption, 'gradesexportactionselect');
+ $this->execute('behat_forms::i_set_the_field_to', [get_string('exportas', 'grades'), $gradeexportoption]);
}
/**
diff --git a/grade/upgrade.txt b/grade/upgrade.txt
index e0ed9a55bca..ca1cc74209c 100644
--- a/grade/upgrade.txt
+++ b/grade/upgrade.txt
@@ -1,6 +1,10 @@
This file describes API changes in /grade/* ;
Information provided here is intended especially for developers.
+=== 4.1 ===
+* The $importactiveurl parameter in the constructor of the core_grades\output\import_action_bar class has been deprecated and is not used anymore.
+* The $exportactiveurl parameter in the constructor of the core_grades\output\export_action_bar class has been deprecated and is not used anymore.
+
=== 4.0 ===
* The select_in_gradebook_tabs() function in behat_grade.php has been deprecated. Please use the function
diff --git a/lang/en/grades.php b/lang/en/grades.php
index 1f58070512e..13ade09c371 100644
--- a/lang/en/grades.php
+++ b/lang/en/grades.php
@@ -212,6 +212,7 @@ $string['excluded_help'] = 'If ticked, the grade will not be included in any agg
$string['expand'] = 'Expand category';
$string['expandcriterion'] = 'Expand criterion';
$string['export'] = 'Export';
+$string['exportas'] = 'Export as';
$string['exportalloutcomes'] = 'Export all outcomes';
$string['exportfeedback'] = 'Include feedback in export';
$string['exportfeedback_desc'] = 'This can be overridden during export.';
@@ -400,6 +401,7 @@ $string['identifier'] = 'Identify user by';
$string['idnumbers'] = 'ID numbers';
$string['ignore'] = 'Ignore';
$string['import'] = 'Import';
+$string['importas'] = 'Import as';
$string['importcsv'] = 'Import CSV';
$string['importcsv_help'] = 'Grades can be imported via a CSV file with format as follows:
diff --git a/lib/behat/behat_field_manager.php b/lib/behat/behat_field_manager.php
index 1b78e535d27..0ee5f700e58 100644
--- a/lib/behat/behat_field_manager.php
+++ b/lib/behat/behat_field_manager.php
@@ -190,6 +190,12 @@ class behat_field_manager {
}
}
+ if ($tagname == 'div') {
+ if ($node->getAttribute('role') == 'combobox') {
+ return 'select_menu';
+ }
+ }
+
// We can not provide a closer field type.
return false;
}
diff --git a/lib/behat/classes/partial_named_selector.php b/lib/behat/classes/partial_named_selector.php
index f0d0ddcb96e..243ca616b12 100644
--- a/lib/behat/classes/partial_named_selector.php
+++ b/lib/behat/classes/partial_named_selector.php
@@ -279,6 +279,11 @@ XPATH
'date_time' => << <<.
+
+declare(strict_types=1);
+
+require_once(__DIR__ . '/behat_form_field.php');
+
+/**
+ * Custom interaction with select_menu elements
+ *
+ * @package core_form
+ * @copyright 2022 Shamim Rezaie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_form_select_menu extends behat_form_field {
+ public function set_value($value) {
+ self::require_javascript();
+
+ $rootnode = $this->field->getParent();
+ $options = $rootnode->findAll('css', '[role=option]');
+ $this->field->click();
+ foreach ($options as $option) {
+ if (trim($option->getHtml()) == $value) {
+ $option->click();
+ break;
+ }
+ }
+ }
+
+ public function get_value() {
+ $rootnode = $this->field->getParent();
+ $input = $rootnode->find('css', 'input');
+ return $input->getValue();
+ }
+}
diff --git a/lib/classes/output/select_menu.php b/lib/classes/output/select_menu.php
new file mode 100644
index 00000000000..ecdb9110132
--- /dev/null
+++ b/lib/classes/output/select_menu.php
@@ -0,0 +1,145 @@
+.
+
+declare(strict_types=1);
+
+namespace core\output;
+
+use renderer_base;
+
+/**
+ * A single-select combobox widget that is functionally similar to an HTML select element.
+ *
+ * @package core
+ * @category output
+ * @copyright 2022 Shamim Rezaie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class select_menu implements \renderable, \templatable {
+ /** @var array List of options. */
+ protected $options;
+
+ /** @var string|null The value of the preselected option. */
+ protected $selected;
+
+ /** @var string The combobox label */
+ protected $label;
+
+ /** @var array Button label's attributes */
+ protected $labelattributes;
+
+ /** @var string Name of the combobox element */
+ protected $name;
+
+ /**
+ * select_menu constructor.
+ *
+ * @param string $name Name of the combobox element
+ * @param array $options List of options in an associative array format like ['val' => 'Option'].
+ * Supports grouped options as well.
+ * @param string|null $selected The value of the preselected option.
+ */
+ public function __construct(string $name, array $options, string $selected = null) {
+ $this->name = $name;
+ $this->options = $options;
+ $this->selected = $selected;
+ }
+
+ /**
+ * Sets the select menu's label.
+ *
+ * @param string $label The label.
+ * @param array $attributes List of attributes to apply on the label element.
+ */
+ public function set_label(string $label, array $attributes = []) {
+ $this->label = $label;
+ $this->labelattributes = $attributes;
+ }
+
+ /**
+ * Flatten the options for Mustache.
+ *
+ * @return array
+ */
+ protected function flatten_options(): array {
+ $flattened = [];
+
+ foreach ($this->options as $value => $option) {
+ if (is_array($option)) {
+ foreach ($option as $groupname => $optoptions) {
+ if (!isset($flattened[$groupname])) {
+ $flattened[$groupname] = [
+ 'name' => $groupname,
+ 'isgroup' => true,
+ 'id' => \html_writer::random_id('select-menu-group'),
+ 'options' => []
+ ];
+ }
+ foreach ($optoptions as $optvalue => $optoption) {
+ $flattened[$groupname]['options'][$optvalue] = [
+ 'name' => $optoption,
+ 'value' => $optvalue,
+ 'selected' => $this->selected == $optvalue,
+ 'id' => \html_writer::random_id('select-menu-option'),
+ ];
+ }
+ }
+ } else {
+ $flattened[$value] = [
+ 'name' => $option,
+ 'value' => $value,
+ 'selected' => $this->selected == $value,
+ 'id' => \html_writer::random_id('select-menu-option'),
+ ];
+ }
+ }
+
+ // Make non-associative array.
+ foreach ($flattened as $key => $value) {
+ if (!empty($value['options'])) {
+ $flattened[$key]['options'] = array_values($value['options']);
+ }
+ }
+ $flattened = array_values($flattened);
+
+ return $flattened;
+ }
+
+ /**
+ * Export for template.
+ *
+ * @param renderer_base $output The renderer.
+ * @return \stdClass
+ */
+ public function export_for_template(renderer_base $output): \stdClass {
+ $data = new \stdClass();
+ $data->baseid = \html_writer::random_id('select-menu');
+ $data->label = $this->label;
+ $data->options = $this->flatten_options($this->options);
+ $data->selectedoption = array_column($data->options, 'name', 'value')[$this->selected];
+ $data->name = $this->name;
+ $data->value = $this->selected;
+
+ // Label attributes.
+ $data->labelattributes = [];
+ // Map the label attributes.
+ foreach ($this->labelattributes as $key => $value) {
+ $data->labelattributes[] = ['name' => $key, 'value' => $value];
+ }
+
+ return $data;
+ }
+}
diff --git a/lib/templates/select_menu.mustache b/lib/templates/select_menu.mustache
new file mode 100644
index 00000000000..d807c4f5b59
--- /dev/null
+++ b/lib/templates/select_menu.mustache
@@ -0,0 +1,133 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template core/select_menu
+
+ Template for select_menu output component.
+
+ Context variables required for this template:
+ * name - name of the form element
+ * value - value of the form element
+ * baseid - id of the dropdown element and to be used to generate id for other elements used internally
+ * label - Element label
+ * labelattributes - Label attributes.
+ * selectedoption - Text of the selected option
+ * options - Array of options for the select with value, name, selected, isgroup and id properites.
+
+ Example context (json):
+ {
+ "name": "menuname",
+ "value": "opt2",
+ "baseid": "select-menu56789",
+ "label": "Select one option",
+ "labelattributes": [
+ {
+ "name": "class",
+ "value": "font-weight-bold"
+ }
+ ],
+ "selectedoption": "Second option",
+ "options": [
+ {
+ "name": "First option",
+ "value": "opt1",
+ "id": "select-menu-option1",
+ "selected": false
+ },
+ {
+ "name": "Second option",
+ "value": "opt2",
+ "id": "select-menu-option2",
+ "selected": true
+ },
+ {
+ "selected": false,
+ "isgroup": {
+ "name": "First group",
+ "id": "select-menu-group1",
+ "options": [
+ {
+ "name": "Third option",
+ "value": "opt3",
+ "id": "select-menu-option3",
+ "selected": false
+ },
+ {
+ "name": "Fourth option",
+ "value": "opt4",
+ "id": "select-menu-option4",
+ "selected": false
+ }
+ ]
+ }
+ },
+ {
+ "name": "Fifth option",
+ "value": "opt5",
+ "id": "select-menu-option5",
+ "selected": false
+ }
+ ]
+ }
+}}
+
+{{#js}}
+ var label = document.getElementById('{{baseid}}-label');
+ if (label) {
+ label.addEventListener('click', function() {
+ label.parentElement.querySelector('.dropdown-toggle').focus();
+ });
+ }
+{{/js}}
diff --git a/theme/boost/amd/build/aria.min.js b/theme/boost/amd/build/aria.min.js
index d9aadba20ee..3e35376a794 100644
--- a/theme/boost/amd/build/aria.min.js
+++ b/theme/boost/amd/build/aria.min.js
@@ -5,6 +5,6 @@ define("theme_boost/aria",["exports","jquery","core/pending"],(function(_exports
* @module theme_boost/aria
* @copyright 2018 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_jquery=_interopRequireDefault(_jquery),_pending=_interopRequireDefault(_pending);const dropdownFix=()=>{let focusEnd=!1;const setFocusEnd=function(){let end=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];focusEnd=end},shiftFocus=element=>{setTimeout((pendingPromise=>{element.focus(),pendingPromise.resolve()}),50,new _pending.default("core/aria:delayed-focus"))},handleMenuButton=e=>{const trigger=e.key;let fixFocus=!1;if(" "!==trigger&&"Enter"!==trigger||(fixFocus=!0,e.preventDefault(),e.target.click()),"ArrowUp"!==trigger&&"ArrowDown"!==trigger||(fixFocus=!0),!fixFocus)return;const menu=e.target.parentElement.querySelector('[role="menu"]');let menuItems=!1,foundMenuItem=!1;menu&&(menuItems=menu.querySelectorAll('[role="menuitem"]')),menuItems&&menuItems.length>0&&("ArrowUp"===trigger?setFocusEnd():setFocusEnd(!1),foundMenuItem=(()=>{const result=focusEnd;return focusEnd=!1,result})()?menuItems[menuItems.length-1]:menuItems[0]),foundMenuItem&&shiftFocus(foundMenuItem)};document.addEventListener("keypress",(e=>{if(e.target.matches('.dropdown [role="menu"] [role="menuitem"]')){const menu=e.target.closest('[role="menu"]');if(!menu)return;const menuItems=menu.querySelectorAll('[role="menuitem"]');if(!menuItems)return;const trigger=e.key.toLowerCase();for(let i=0;i{if(e.target.matches('[data-toggle="dropdown"]')&&handleMenuButton(e),e.target.matches('.dropdown [role="menu"] [role="menuitem"]')){const trigger=e.key;let next=!1;const menu=e.target.closest('[role="menu"]');if(!menu)return;const menuItems=menu.querySelectorAll('[role="menuitem"]');if(!menuItems)return;if("ArrowDown"==trigger){for(let i=0;i{const trigger=e.target.querySelector('[data-toggle="dropdown"]'),focused=document.activeElement!=document.body?document.activeElement:null;trigger&&focused&&e.target.contains(focused)&&shiftFocus(trigger)}))},tabElementFix=()=>{document.addEventListener("keydown",(e=>{["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End"].includes(e.key)&&e.target.matches('[role="tablist"] [role="tab"]')&&(e=>{const tabList=e.target.closest('[role="tablist"]'),vertical="vertical"==tabList.getAttribute("aria-orientation"),rtl=window.right_to_left(),arrowNext=vertical?"ArrowDown":rtl?"ArrowLeft":"ArrowRight",arrowPrevious=vertical?"ArrowUp":rtl?"ArrowRight":"ArrowLeft",tabs=Array.prototype.filter.call(tabList.querySelectorAll('[role="tab"]'),(tab=>!!tab.offsetHeight));for(let i=0;i{if(e.target.matches('[role="tablist"] [data-toggle="tab"], [role="tablist"] [data-toggle="pill"]')){const tabs=e.target.closest('[role="tablist"]').querySelectorAll('[data-toggle="tab"], [data-toggle="pill"]');e.preventDefault(),(0,_jquery.default)(e.target).tab("show"),tabs.forEach((tab=>{tab.tabIndex=-1})),e.target.tabIndex=0}}))};_exports.init=()=>{dropdownFix(),window.addEventListener("load",(()=>{const alerts=document.querySelectorAll('[data-aria-autofocus="true"][role="alert"]');Array.prototype.forEach.call(alerts,(autofocusElement=>{autofocusElement.innerHTML+=" ",autofocusElement.removeAttribute("data-aria-autofocus")}))})),tabElementFix(),document.addEventListener("keydown",(e=>{e.target.matches('[data-toggle="collapse"]')&&" "===e.key&&(e.preventDefault(),e.target.click())}))}}));
+ */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.init=void 0,_jquery=_interopRequireDefault(_jquery),_pending=_interopRequireDefault(_pending);const dropdownFix=()=>{let focusEnd=!1;const setFocusEnd=function(){let end=!(arguments.length>0&&void 0!==arguments[0])||arguments[0];focusEnd=end},shiftFocus=element=>{setTimeout((pendingPromise=>{element.focus(),pendingPromise.resolve()}),50,new _pending.default("core/aria:delayed-focus"))},handleMenuButton=e=>{const trigger=e.key;let fixFocus=!1;if(" "!==trigger&&"Enter"!==trigger||(fixFocus=!0,e.preventDefault(),e.target.click()),"ArrowUp"!==trigger&&"ArrowDown"!==trigger||(fixFocus=!0),!fixFocus)return;const menu=e.target.parentElement.querySelector('[role="menu"]');let menuItems=!1,foundMenuItem=!1;menu&&(menuItems=menu.querySelectorAll('[role="menuitem"]')),menuItems&&menuItems.length>0&&("ArrowUp"===trigger?setFocusEnd():setFocusEnd(!1),foundMenuItem=(()=>{const result=focusEnd;return focusEnd=!1,result})()?menuItems[menuItems.length-1]:menuItems[0]),foundMenuItem&&shiftFocus(foundMenuItem)};document.addEventListener("keypress",(e=>{if(e.target.matches('.dropdown [role="menu"] [role="menuitem"]')){const menu=e.target.closest('[role="menu"]');if(!menu)return;const menuItems=menu.querySelectorAll('[role="menuitem"]');if(!menuItems)return;const trigger=e.key.toLowerCase();for(let i=0;i{if(e.target.matches('[data-toggle="dropdown"]')&&handleMenuButton(e),e.target.matches('.dropdown [role="menu"] [role="menuitem"]')){const trigger=e.key;let next=!1;const menu=e.target.closest('[role="menu"]');if(!menu)return;const menuItems=menu.querySelectorAll('[role="menuitem"]');if(!menuItems)return;if("ArrowDown"==trigger){for(let i=0;i{const trigger=e.target.querySelector('[data-toggle="dropdown"]'),focused=document.activeElement!=document.body?document.activeElement:null;trigger&&focused&&e.target.contains(focused)&&shiftFocus(trigger)}))},tabElementFix=()=>{document.addEventListener("keydown",(e=>{["ArrowUp","ArrowDown","ArrowLeft","ArrowRight","Home","End"].includes(e.key)&&e.target.matches('[role="tablist"] [role="tab"]')&&(e=>{const tabList=e.target.closest('[role="tablist"]'),vertical="vertical"==tabList.getAttribute("aria-orientation"),rtl=window.right_to_left(),arrowNext=vertical?"ArrowDown":rtl?"ArrowLeft":"ArrowRight",arrowPrevious=vertical?"ArrowUp":rtl?"ArrowRight":"ArrowLeft",tabs=Array.prototype.filter.call(tabList.querySelectorAll('[role="tab"]'),(tab=>!!tab.offsetHeight));for(let i=0;i{if(e.target.matches('[role="tablist"] [data-toggle="tab"], [role="tablist"] [data-toggle="pill"]')){const tabs=e.target.closest('[role="tablist"]').querySelectorAll('[data-toggle="tab"], [data-toggle="pill"]');e.preventDefault(),(0,_jquery.default)(e.target).tab("show"),tabs.forEach((tab=>{tab.tabIndex=-1})),e.target.tabIndex=0}}))};_exports.init=()=>{dropdownFix(),(()=>{(0,_jquery.default)(document).on("show.bs.dropdown",(e=>{if(e.relatedTarget.matches('[role="combobox"]')){const combobox=e.relatedTarget,listbox=combobox.parentElement.querySelector('[role="listbox"]'),selectedOption=listbox.querySelector('[role="option"][aria-selected="true"]');setTimeout((()=>{if(selectedOption)selectedOption.classList.add("active"),combobox.setAttribute("aria-activedescendant",selectedOption.id);else{const firstOption=listbox.querySelector('[role="option"]');firstOption.setAttribute("aria-selected","true"),firstOption.classList.add("active"),combobox.setAttribute("aria-activedescendant",firstOption.id)}}),0)}})),(0,_jquery.default)(document).on("hidden.bs.dropdown",(e=>{if(e.relatedTarget.matches('[role="combobox"]')){const combobox=e.relatedTarget,listbox=combobox.parentElement.querySelector('[role="listbox"]');combobox.removeAttribute("aria-activedescendant"),setTimeout((()=>{listbox.querySelectorAll('.active[role="option"]').forEach((option=>{option.classList.remove("active")}))}),0)}})),document.addEventListener("keydown",(e=>{if(e.target.matches('.select-menu [role="combobox"]')){const combobox=e.target,trigger=e.key;let next=null;const options=combobox.parentElement.querySelectorAll('[role="listbox"] [role="option"]'),activeOption=combobox.parentElement.querySelector('[role="listbox"] .active[role="option"]');if(options&&activeOption){if("ArrowDown"==trigger)for(let i=0;i{if(e.target.matches('.select-menu [role="option"]')){const option=e.target,combobox=option.closest(".select-menu").querySelector('[role="combobox"]');combobox.focus(),selectOption(combobox,option)}})),document.addEventListener("change",(e=>{if(e.target.matches('.select-menu input[type="hidden"]')){const combobox=e.target.parentElement.querySelector('[role="combobox"]'),option=e.target.parentElement.querySelector('[role="option"][data-value="'.concat(e.target.value,'"]'));combobox&&option&&selectOption(combobox,option)}}));const selectOption=(combobox,option)=>{const oldSelectedOption=combobox.parentElement.querySelector('[role="listbox"] [role="option"][aria-selected="true"]'),inputElement=combobox.parentElement.querySelector('input[type="hidden"]');oldSelectedOption!=option&&(oldSelectedOption&&oldSelectedOption.removeAttribute("aria-selected"),option.setAttribute("aria-selected","true")),combobox.textContent=option.textContent,inputElement.value!=option.dataset.value&&(inputElement.value=option.dataset.value,inputElement.dispatchEvent(new Event("change",{bubbles:!0})))}})(),window.addEventListener("load",(()=>{const alerts=document.querySelectorAll('[data-aria-autofocus="true"][role="alert"]');Array.prototype.forEach.call(alerts,(autofocusElement=>{autofocusElement.innerHTML+=" ",autofocusElement.removeAttribute("data-aria-autofocus")}))})),tabElementFix(),document.addEventListener("keydown",(e=>{e.target.matches('[data-toggle="collapse"]')&&" "===e.key&&(e.preventDefault(),e.target.click())}))}}));
//# sourceMappingURL=aria.min.js.map
\ No newline at end of file
diff --git a/theme/boost/amd/build/aria.min.js.map b/theme/boost/amd/build/aria.min.js.map
index 81706ea635f..6fd9e2d9815 100644
--- a/theme/boost/amd/build/aria.min.js.map
+++ b/theme/boost/amd/build/aria.min.js.map
@@ -1 +1 @@
-{"version":3,"file":"aria.min.js","sources":["../src/aria.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Enhancements to Bootstrap components for accessibility.\n *\n * @module theme_boost/aria\n * @copyright 2018 Damyon Wiese \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport Pending from 'core/pending';\n\n/**\n * Drop downs from bootstrap don't support keyboard accessibility by default.\n */\nconst dropdownFix = () => {\n let focusEnd = false;\n const setFocusEnd = (end = true) => {\n focusEnd = end;\n };\n const getFocusEnd = () => {\n const result = focusEnd;\n focusEnd = false;\n return result;\n };\n\n // Special handling for navigation keys when menu is open.\n const shiftFocus = element => {\n const delayedFocus = pendingPromise => {\n element.focus();\n pendingPromise.resolve();\n };\n setTimeout(delayedFocus, 50, new Pending('core/aria:delayed-focus'));\n };\n\n // Event handling for the dropdown menu button.\n const handleMenuButton = e => {\n const trigger = e.key;\n let fixFocus = false;\n\n // Space key or Enter key opens the menu.\n if (trigger === ' ' || trigger === 'Enter') {\n fixFocus = true;\n // Cancel random scroll.\n e.preventDefault();\n // Open the menu instead.\n e.target.click();\n }\n\n // Up and Down keys also open the menu.\n if (trigger === 'ArrowUp' || trigger === 'ArrowDown') {\n fixFocus = true;\n }\n\n if (!fixFocus) {\n // No need to fix the focus. Return early.\n return;\n }\n\n // Fix the focus on the menu items when the menu is opened.\n const menu = e.target.parentElement.querySelector('[role=\"menu\"]');\n let menuItems = false;\n let foundMenuItem = false;\n\n if (menu) {\n menuItems = menu.querySelectorAll('[role=\"menuitem\"]');\n }\n if (menuItems && menuItems.length > 0) {\n // Up key opens the menu at the end.\n if (trigger === 'ArrowUp') {\n setFocusEnd();\n } else {\n setFocusEnd(false);\n }\n\n if (getFocusEnd()) {\n foundMenuItem = menuItems[menuItems.length - 1];\n } else {\n // The first menu entry, pretty reasonable.\n foundMenuItem = menuItems[0];\n }\n }\n\n if (foundMenuItem) {\n shiftFocus(foundMenuItem);\n }\n };\n\n // Search for menu items by finding the first item that has\n // text starting with the typed character (case insensitive).\n document.addEventListener('keypress', e => {\n if (e.target.matches('.dropdown [role=\"menu\"] [role=\"menuitem\"]')) {\n const menu = e.target.closest('[role=\"menu\"]');\n if (!menu) {\n return;\n }\n const menuItems = menu.querySelectorAll('[role=\"menuitem\"]');\n if (!menuItems) {\n return;\n }\n\n const trigger = e.key.toLowerCase();\n\n for (let i = 0; i < menuItems.length; i++) {\n const item = menuItems[i];\n const itemText = item.text.trim().toLowerCase();\n if (itemText.indexOf(trigger) == 0) {\n shiftFocus(item);\n break;\n }\n }\n }\n });\n\n // Keyboard navigation for arrow keys, home and end keys.\n document.addEventListener('keydown', e => {\n\n // We only want to set focus when users access the dropdown via keyboard as per\n // guidelines defined in w3 aria practices 1.1 menu-button.\n if (e.target.matches('[data-toggle=\"dropdown\"]')) {\n handleMenuButton(e);\n }\n\n if (e.target.matches('.dropdown [role=\"menu\"] [role=\"menuitem\"]')) {\n const trigger = e.key;\n let next = false;\n const menu = e.target.closest('[role=\"menu\"]');\n\n if (!menu) {\n return;\n }\n const menuItems = menu.querySelectorAll('[role=\"menuitem\"]');\n if (!menuItems) {\n return;\n }\n // Down key.\n if (trigger == 'ArrowDown') {\n for (let i = 0; i < menuItems.length - 1; i++) {\n if (menuItems[i] == e.target) {\n next = menuItems[i + 1];\n break;\n }\n }\n if (!next) {\n // Wrap to first item.\n next = menuItems[0];\n }\n } else if (trigger == 'ArrowUp') {\n // Up key.\n for (let i = 1; i < menuItems.length; i++) {\n if (menuItems[i] == e.target) {\n next = menuItems[i - 1];\n break;\n }\n }\n if (!next) {\n // Wrap to last item.\n next = menuItems[menuItems.length - 1];\n }\n } else if (trigger == 'Home') {\n // Home key.\n next = menuItems[0];\n\n } else if (trigger == 'End') {\n // End key.\n next = menuItems[menuItems.length - 1];\n }\n\n // Variable next is set if we do want to act on the keypress.\n if (next) {\n e.preventDefault();\n shiftFocus(next);\n }\n return;\n }\n });\n\n $('.dropdown').on('hidden.bs.dropdown', e => {\n // We need to focus on the menu trigger.\n const trigger = e.target.querySelector('[data-toggle=\"dropdown\"]');\n const focused = document.activeElement != document.body ? document.activeElement : null;\n if (trigger && focused && e.target.contains(focused)) {\n shiftFocus(trigger);\n }\n });\n};\n\n/**\n * After page load, focus on any element with special autofocus attribute.\n */\nconst autoFocus = () => {\n window.addEventListener(\"load\", () => {\n const alerts = document.querySelectorAll('[data-aria-autofocus=\"true\"][role=\"alert\"]');\n Array.prototype.forEach.call(alerts, autofocusElement => {\n // According to the specification an role=\"alert\" region is only read out on change to the content\n // of that region.\n autofocusElement.innerHTML += ' ';\n autofocusElement.removeAttribute('data-aria-autofocus');\n });\n });\n};\n\n/**\n * Changes the focus to the correct tab based on the key that is pressed.\n * @param {KeyboardEvent} e\n */\nconst updateTabFocus = e => {\n const tabList = e.target.closest('[role=\"tablist\"]');\n const vertical = tabList.getAttribute('aria-orientation') == 'vertical';\n const rtl = window.right_to_left();\n const arrowNext = vertical ? 'ArrowDown' : (rtl ? 'ArrowLeft' : 'ArrowRight');\n const arrowPrevious = vertical ? 'ArrowUp' : (rtl ? 'ArrowRight' : 'ArrowLeft');\n const tabs = Array.prototype.filter.call(\n tabList.querySelectorAll('[role=\"tab\"]'),\n tab => !!tab.offsetHeight); // We only work with the visible tabs.\n\n for (let i = 0; i < tabs.length; i++) {\n tabs[i].index = i;\n }\n\n switch (e.key) {\n case arrowNext:\n e.preventDefault();\n if (e.target.index !== undefined && tabs[e.target.index + 1]) {\n tabs[e.target.index + 1].focus();\n } else {\n tabs[0].focus();\n }\n break;\n case arrowPrevious:\n e.preventDefault();\n if (e.target.index !== undefined && tabs[e.target.index - 1]) {\n tabs[e.target.index - 1].focus();\n } else {\n tabs[tabs.length - 1].focus();\n }\n break;\n case 'Home':\n e.preventDefault();\n tabs[0].focus();\n break;\n case 'End':\n e.preventDefault();\n tabs[tabs.length - 1].focus();\n }\n};\n\n/**\n * Fix accessibility issues regarding tab elements focus and their tab order in Bootstrap navs.\n */\nconst tabElementFix = () => {\n document.addEventListener('keydown', e => {\n if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {\n if (e.target.matches('[role=\"tablist\"] [role=\"tab\"]')) {\n updateTabFocus(e);\n }\n }\n });\n\n document.addEventListener('click', e => {\n if (e.target.matches('[role=\"tablist\"] [data-toggle=\"tab\"], [role=\"tablist\"] [data-toggle=\"pill\"]')) {\n const tabs = e.target.closest('[role=\"tablist\"]').querySelectorAll('[data-toggle=\"tab\"], [data-toggle=\"pill\"]');\n e.preventDefault();\n $(e.target).tab('show');\n tabs.forEach(tab => {\n tab.tabIndex = -1;\n });\n e.target.tabIndex = 0;\n }\n });\n};\n\n/**\n * Fix keyboard interaction with Bootstrap Collapse elements.\n *\n * @see {@link https://www.w3.org/TR/wai-aria-practices-1.1/#disclosure|WAI-ARIA Authoring Practices 1.1 - Disclosure (Show/Hide)}\n */\nconst collapseFix = () => {\n document.addEventListener('keydown', e => {\n if (e.target.matches('[data-toggle=\"collapse\"]')) {\n // Pressing space should toggle expand/collapse.\n if (e.key === ' ') {\n e.preventDefault();\n e.target.click();\n }\n }\n });\n};\n\nexport const init = () => {\n dropdownFix();\n autoFocus();\n tabElementFix();\n collapseFix();\n};\n"],"names":["dropdownFix","focusEnd","setFocusEnd","end","shiftFocus","element","setTimeout","pendingPromise","focus","resolve","Pending","handleMenuButton","e","trigger","key","fixFocus","preventDefault","target","click","menu","parentElement","querySelector","menuItems","foundMenuItem","querySelectorAll","length","result","getFocusEnd","document","addEventListener","matches","closest","toLowerCase","i","item","text","trim","indexOf","next","on","focused","activeElement","body","contains","tabElementFix","includes","tabList","vertical","getAttribute","rtl","window","right_to_left","arrowNext","arrowPrevious","tabs","Array","prototype","filter","call","tab","offsetHeight","index","undefined","updateTabFocus","forEach","tabIndex","alerts","autofocusElement","innerHTML","removeAttribute"],"mappings":"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"}
\ No newline at end of file
+{"version":3,"file":"aria.min.js","sources":["../src/aria.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Enhancements to Bootstrap components for accessibility.\n *\n * @module theme_boost/aria\n * @copyright 2018 Damyon Wiese \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport Pending from 'core/pending';\n\n/**\n * Drop downs from bootstrap don't support keyboard accessibility by default.\n */\nconst dropdownFix = () => {\n let focusEnd = false;\n const setFocusEnd = (end = true) => {\n focusEnd = end;\n };\n const getFocusEnd = () => {\n const result = focusEnd;\n focusEnd = false;\n return result;\n };\n\n // Special handling for navigation keys when menu is open.\n const shiftFocus = element => {\n const delayedFocus = pendingPromise => {\n element.focus();\n pendingPromise.resolve();\n };\n setTimeout(delayedFocus, 50, new Pending('core/aria:delayed-focus'));\n };\n\n // Event handling for the dropdown menu button.\n const handleMenuButton = e => {\n const trigger = e.key;\n let fixFocus = false;\n\n // Space key or Enter key opens the menu.\n if (trigger === ' ' || trigger === 'Enter') {\n fixFocus = true;\n // Cancel random scroll.\n e.preventDefault();\n // Open the menu instead.\n e.target.click();\n }\n\n // Up and Down keys also open the menu.\n if (trigger === 'ArrowUp' || trigger === 'ArrowDown') {\n fixFocus = true;\n }\n\n if (!fixFocus) {\n // No need to fix the focus. Return early.\n return;\n }\n\n // Fix the focus on the menu items when the menu is opened.\n const menu = e.target.parentElement.querySelector('[role=\"menu\"]');\n let menuItems = false;\n let foundMenuItem = false;\n\n if (menu) {\n menuItems = menu.querySelectorAll('[role=\"menuitem\"]');\n }\n if (menuItems && menuItems.length > 0) {\n // Up key opens the menu at the end.\n if (trigger === 'ArrowUp') {\n setFocusEnd();\n } else {\n setFocusEnd(false);\n }\n\n if (getFocusEnd()) {\n foundMenuItem = menuItems[menuItems.length - 1];\n } else {\n // The first menu entry, pretty reasonable.\n foundMenuItem = menuItems[0];\n }\n }\n\n if (foundMenuItem) {\n shiftFocus(foundMenuItem);\n }\n };\n\n // Search for menu items by finding the first item that has\n // text starting with the typed character (case insensitive).\n document.addEventListener('keypress', e => {\n if (e.target.matches('.dropdown [role=\"menu\"] [role=\"menuitem\"]')) {\n const menu = e.target.closest('[role=\"menu\"]');\n if (!menu) {\n return;\n }\n const menuItems = menu.querySelectorAll('[role=\"menuitem\"]');\n if (!menuItems) {\n return;\n }\n\n const trigger = e.key.toLowerCase();\n\n for (let i = 0; i < menuItems.length; i++) {\n const item = menuItems[i];\n const itemText = item.text.trim().toLowerCase();\n if (itemText.indexOf(trigger) == 0) {\n shiftFocus(item);\n break;\n }\n }\n }\n });\n\n // Keyboard navigation for arrow keys, home and end keys.\n document.addEventListener('keydown', e => {\n\n // We only want to set focus when users access the dropdown via keyboard as per\n // guidelines defined in w3 aria practices 1.1 menu-button.\n if (e.target.matches('[data-toggle=\"dropdown\"]')) {\n handleMenuButton(e);\n }\n\n if (e.target.matches('.dropdown [role=\"menu\"] [role=\"menuitem\"]')) {\n const trigger = e.key;\n let next = false;\n const menu = e.target.closest('[role=\"menu\"]');\n\n if (!menu) {\n return;\n }\n const menuItems = menu.querySelectorAll('[role=\"menuitem\"]');\n if (!menuItems) {\n return;\n }\n // Down key.\n if (trigger == 'ArrowDown') {\n for (let i = 0; i < menuItems.length - 1; i++) {\n if (menuItems[i] == e.target) {\n next = menuItems[i + 1];\n break;\n }\n }\n if (!next) {\n // Wrap to first item.\n next = menuItems[0];\n }\n } else if (trigger == 'ArrowUp') {\n // Up key.\n for (let i = 1; i < menuItems.length; i++) {\n if (menuItems[i] == e.target) {\n next = menuItems[i - 1];\n break;\n }\n }\n if (!next) {\n // Wrap to last item.\n next = menuItems[menuItems.length - 1];\n }\n } else if (trigger == 'Home') {\n // Home key.\n next = menuItems[0];\n\n } else if (trigger == 'End') {\n // End key.\n next = menuItems[menuItems.length - 1];\n }\n\n // Variable next is set if we do want to act on the keypress.\n if (next) {\n e.preventDefault();\n shiftFocus(next);\n }\n return;\n }\n });\n\n $('.dropdown').on('hidden.bs.dropdown', e => {\n // We need to focus on the menu trigger.\n const trigger = e.target.querySelector('[data-toggle=\"dropdown\"]');\n const focused = document.activeElement != document.body ? document.activeElement : null;\n if (trigger && focused && e.target.contains(focused)) {\n shiftFocus(trigger);\n }\n });\n};\n\n/**\n * A lot of Bootstrap's out of the box features don't work if dropdown items are not focusable.\n */\nconst comboboxFix = () => {\n $(document).on('show.bs.dropdown', e => {\n if (e.relatedTarget.matches('[role=\"combobox\"]')) {\n const combobox = e.relatedTarget;\n const listbox = combobox.parentElement.querySelector('[role=\"listbox\"]');\n const selectedOption = listbox.querySelector('[role=\"option\"][aria-selected=\"true\"]');\n\n // To make sure ArrowDown doesn't move the active option afterwards.\n setTimeout(() => {\n if (selectedOption) {\n selectedOption.classList.add('active');\n combobox.setAttribute('aria-activedescendant', selectedOption.id);\n } else {\n const firstOption = listbox.querySelector('[role=\"option\"]');\n firstOption.setAttribute('aria-selected', 'true');\n firstOption.classList.add('active');\n combobox.setAttribute('aria-activedescendant', firstOption.id);\n }\n }, 0);\n }\n });\n\n $(document).on('hidden.bs.dropdown', e => {\n if (e.relatedTarget.matches('[role=\"combobox\"]')) {\n const combobox = e.relatedTarget;\n const listbox = combobox.parentElement.querySelector('[role=\"listbox\"]');\n\n combobox.removeAttribute('aria-activedescendant');\n\n setTimeout(() => {\n // Undo all previously highlighted options.\n listbox.querySelectorAll('.active[role=\"option\"]').forEach(option => {\n option.classList.remove('active');\n });\n }, 0);\n }\n });\n\n // Handling keyboard events for both navigating through and selecting options.\n document.addEventListener('keydown', e => {\n if (e.target.matches('.select-menu [role=\"combobox\"]')) {\n const combobox = e.target;\n const trigger = e.key;\n let next = null;\n const options = combobox.parentElement.querySelectorAll('[role=\"listbox\"] [role=\"option\"]');\n const activeOption = combobox.parentElement.querySelector('[role=\"listbox\"] .active[role=\"option\"]');\n\n // Under the special case that the dropdown menu is being shown as a result of they key press (like when the user\n // presses ArrowDown or Enter or ... to open the dropdown menu), activeOption is not set yet.\n // It's because of a race condition with show.bs.dropdown event handler.\n if (options && activeOption) {\n if (trigger == 'ArrowDown') {\n for (let i = 0; i < options.length - 1; i++) {\n if (options[i] == activeOption) {\n next = options[i + 1];\n break;\n }\n }\n } if (trigger == 'ArrowUp') {\n for (let i = 1; i < options.length; i++) {\n if (options[i] == activeOption) {\n next = options[i - 1];\n break;\n }\n }\n } else if (trigger == 'Home') {\n next = options[0];\n } else if (trigger == 'End') {\n next = options[options.length - 1];\n } else if (trigger == ' ' || trigger == 'Enter') {\n selectOption(combobox, activeOption);\n } else {\n // Search for options by finding the first option that has\n // text starting with the typed character (case insensitive).\n for (let i = 0; i < options.length; i++) {\n const option = options[i];\n const optionText = option.textContent.trim().toLowerCase();\n const keyPressed = e.key.toLowerCase();\n if (optionText.indexOf(keyPressed) == 0) {\n next = option;\n break;\n }\n }\n }\n\n // Variable next is set if we do want to act on the keypress.\n if (next) {\n e.preventDefault();\n activeOption.classList.remove('active');\n next.classList.add('active');\n combobox.setAttribute('aria-activedescendant', next.id);\n }\n }\n }\n });\n\n document.addEventListener('click', e => {\n if (e.target.matches('.select-menu [role=\"option\"]')) {\n const option = e.target;\n const combobox = option.closest('.select-menu').querySelector('[role=\"combobox\"]');\n combobox.focus();\n selectOption(combobox, option);\n }\n });\n\n // In case some code somewhere else changes the value of the combobox.\n document.addEventListener('change', e => {\n if (e.target.matches('.select-menu input[type=\"hidden\"]')) {\n const combobox = e.target.parentElement.querySelector('[role=\"combobox\"]');\n const option = e.target.parentElement.querySelector(`[role=\"option\"][data-value=\"${e.target.value}\"]`);\n\n if (combobox && option) {\n selectOption(combobox, option);\n }\n }\n });\n\n const selectOption = (combobox, option) => {\n const oldSelectedOption = combobox.parentElement.querySelector('[role=\"listbox\"] [role=\"option\"][aria-selected=\"true\"]');\n const inputElement = combobox.parentElement.querySelector('input[type=\"hidden\"]');\n\n if (oldSelectedOption != option) {\n if (oldSelectedOption) {\n oldSelectedOption.removeAttribute('aria-selected');\n }\n option.setAttribute('aria-selected', 'true');\n }\n combobox.textContent = option.textContent;\n if (inputElement.value != option.dataset.value) {\n inputElement.value = option.dataset.value;\n inputElement.dispatchEvent(new Event('change', {bubbles: true}));\n }\n };\n};\n\n/**\n * After page load, focus on any element with special autofocus attribute.\n */\nconst autoFocus = () => {\n window.addEventListener(\"load\", () => {\n const alerts = document.querySelectorAll('[data-aria-autofocus=\"true\"][role=\"alert\"]');\n Array.prototype.forEach.call(alerts, autofocusElement => {\n // According to the specification an role=\"alert\" region is only read out on change to the content\n // of that region.\n autofocusElement.innerHTML += ' ';\n autofocusElement.removeAttribute('data-aria-autofocus');\n });\n });\n};\n\n/**\n * Changes the focus to the correct tab based on the key that is pressed.\n * @param {KeyboardEvent} e\n */\nconst updateTabFocus = e => {\n const tabList = e.target.closest('[role=\"tablist\"]');\n const vertical = tabList.getAttribute('aria-orientation') == 'vertical';\n const rtl = window.right_to_left();\n const arrowNext = vertical ? 'ArrowDown' : (rtl ? 'ArrowLeft' : 'ArrowRight');\n const arrowPrevious = vertical ? 'ArrowUp' : (rtl ? 'ArrowRight' : 'ArrowLeft');\n const tabs = Array.prototype.filter.call(\n tabList.querySelectorAll('[role=\"tab\"]'),\n tab => !!tab.offsetHeight); // We only work with the visible tabs.\n\n for (let i = 0; i < tabs.length; i++) {\n tabs[i].index = i;\n }\n\n switch (e.key) {\n case arrowNext:\n e.preventDefault();\n if (e.target.index !== undefined && tabs[e.target.index + 1]) {\n tabs[e.target.index + 1].focus();\n } else {\n tabs[0].focus();\n }\n break;\n case arrowPrevious:\n e.preventDefault();\n if (e.target.index !== undefined && tabs[e.target.index - 1]) {\n tabs[e.target.index - 1].focus();\n } else {\n tabs[tabs.length - 1].focus();\n }\n break;\n case 'Home':\n e.preventDefault();\n tabs[0].focus();\n break;\n case 'End':\n e.preventDefault();\n tabs[tabs.length - 1].focus();\n }\n};\n\n/**\n * Fix accessibility issues regarding tab elements focus and their tab order in Bootstrap navs.\n */\nconst tabElementFix = () => {\n document.addEventListener('keydown', e => {\n if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) {\n if (e.target.matches('[role=\"tablist\"] [role=\"tab\"]')) {\n updateTabFocus(e);\n }\n }\n });\n\n document.addEventListener('click', e => {\n if (e.target.matches('[role=\"tablist\"] [data-toggle=\"tab\"], [role=\"tablist\"] [data-toggle=\"pill\"]')) {\n const tabs = e.target.closest('[role=\"tablist\"]').querySelectorAll('[data-toggle=\"tab\"], [data-toggle=\"pill\"]');\n e.preventDefault();\n $(e.target).tab('show');\n tabs.forEach(tab => {\n tab.tabIndex = -1;\n });\n e.target.tabIndex = 0;\n }\n });\n};\n\n/**\n * Fix keyboard interaction with Bootstrap Collapse elements.\n *\n * @see {@link https://www.w3.org/TR/wai-aria-practices-1.1/#disclosure|WAI-ARIA Authoring Practices 1.1 - Disclosure (Show/Hide)}\n */\nconst collapseFix = () => {\n document.addEventListener('keydown', e => {\n if (e.target.matches('[data-toggle=\"collapse\"]')) {\n // Pressing space should toggle expand/collapse.\n if (e.key === ' ') {\n e.preventDefault();\n e.target.click();\n }\n }\n });\n};\n\nexport const init = () => {\n dropdownFix();\n comboboxFix();\n autoFocus();\n tabElementFix();\n collapseFix();\n};\n"],"names":["dropdownFix","focusEnd","setFocusEnd","end","shiftFocus","element","setTimeout","pendingPromise","focus","resolve","Pending","handleMenuButton","e","trigger","key","fixFocus","preventDefault","target","click","menu","parentElement","querySelector","menuItems","foundMenuItem","querySelectorAll","length","result","getFocusEnd","document","addEventListener","matches","closest","toLowerCase","i","item","text","trim","indexOf","next","on","focused","activeElement","body","contains","tabElementFix","includes","tabList","vertical","getAttribute","rtl","window","right_to_left","arrowNext","arrowPrevious","tabs","Array","prototype","filter","call","tab","offsetHeight","index","undefined","updateTabFocus","forEach","tabIndex","relatedTarget","combobox","listbox","selectedOption","classList","add","setAttribute","id","firstOption","removeAttribute","option","remove","options","activeOption","selectOption","optionText","textContent","keyPressed","value","oldSelectedOption","inputElement","dataset","dispatchEvent","Event","bubbles","comboboxFix","alerts","autofocusElement","innerHTML"],"mappings":"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"}
\ No newline at end of file
diff --git a/theme/boost/amd/src/aria.js b/theme/boost/amd/src/aria.js
index 653a3fadcf0..05c9f934abe 100644
--- a/theme/boost/amd/src/aria.js
+++ b/theme/boost/amd/src/aria.js
@@ -199,6 +199,144 @@ const dropdownFix = () => {
});
};
+/**
+ * A lot of Bootstrap's out of the box features don't work if dropdown items are not focusable.
+ */
+const comboboxFix = () => {
+ $(document).on('show.bs.dropdown', e => {
+ if (e.relatedTarget.matches('[role="combobox"]')) {
+ const combobox = e.relatedTarget;
+ const listbox = combobox.parentElement.querySelector('[role="listbox"]');
+ const selectedOption = listbox.querySelector('[role="option"][aria-selected="true"]');
+
+ // To make sure ArrowDown doesn't move the active option afterwards.
+ setTimeout(() => {
+ if (selectedOption) {
+ selectedOption.classList.add('active');
+ combobox.setAttribute('aria-activedescendant', selectedOption.id);
+ } else {
+ const firstOption = listbox.querySelector('[role="option"]');
+ firstOption.setAttribute('aria-selected', 'true');
+ firstOption.classList.add('active');
+ combobox.setAttribute('aria-activedescendant', firstOption.id);
+ }
+ }, 0);
+ }
+ });
+
+ $(document).on('hidden.bs.dropdown', e => {
+ if (e.relatedTarget.matches('[role="combobox"]')) {
+ const combobox = e.relatedTarget;
+ const listbox = combobox.parentElement.querySelector('[role="listbox"]');
+
+ combobox.removeAttribute('aria-activedescendant');
+
+ setTimeout(() => {
+ // Undo all previously highlighted options.
+ listbox.querySelectorAll('.active[role="option"]').forEach(option => {
+ option.classList.remove('active');
+ });
+ }, 0);
+ }
+ });
+
+ // Handling keyboard events for both navigating through and selecting options.
+ document.addEventListener('keydown', e => {
+ if (e.target.matches('.select-menu [role="combobox"]')) {
+ const combobox = e.target;
+ const trigger = e.key;
+ let next = null;
+ const options = combobox.parentElement.querySelectorAll('[role="listbox"] [role="option"]');
+ const activeOption = combobox.parentElement.querySelector('[role="listbox"] .active[role="option"]');
+
+ // Under the special case that the dropdown menu is being shown as a result of they key press (like when the user
+ // presses ArrowDown or Enter or ... to open the dropdown menu), activeOption is not set yet.
+ // It's because of a race condition with show.bs.dropdown event handler.
+ if (options && activeOption) {
+ if (trigger == 'ArrowDown') {
+ for (let i = 0; i < options.length - 1; i++) {
+ if (options[i] == activeOption) {
+ next = options[i + 1];
+ break;
+ }
+ }
+ } if (trigger == 'ArrowUp') {
+ for (let i = 1; i < options.length; i++) {
+ if (options[i] == activeOption) {
+ next = options[i - 1];
+ break;
+ }
+ }
+ } else if (trigger == 'Home') {
+ next = options[0];
+ } else if (trigger == 'End') {
+ next = options[options.length - 1];
+ } else if (trigger == ' ' || trigger == 'Enter') {
+ selectOption(combobox, activeOption);
+ } else {
+ // Search for options by finding the first option that has
+ // text starting with the typed character (case insensitive).
+ for (let i = 0; i < options.length; i++) {
+ const option = options[i];
+ const optionText = option.textContent.trim().toLowerCase();
+ const keyPressed = e.key.toLowerCase();
+ if (optionText.indexOf(keyPressed) == 0) {
+ next = option;
+ break;
+ }
+ }
+ }
+
+ // Variable next is set if we do want to act on the keypress.
+ if (next) {
+ e.preventDefault();
+ activeOption.classList.remove('active');
+ next.classList.add('active');
+ combobox.setAttribute('aria-activedescendant', next.id);
+ }
+ }
+ }
+ });
+
+ document.addEventListener('click', e => {
+ if (e.target.matches('.select-menu [role="option"]')) {
+ const option = e.target;
+ const combobox = option.closest('.select-menu').querySelector('[role="combobox"]');
+ combobox.focus();
+ selectOption(combobox, option);
+ }
+ });
+
+ // In case some code somewhere else changes the value of the combobox.
+ document.addEventListener('change', e => {
+ if (e.target.matches('.select-menu input[type="hidden"]')) {
+ const combobox = e.target.parentElement.querySelector('[role="combobox"]');
+ const option = e.target.parentElement.querySelector(`[role="option"][data-value="${e.target.value}"]`);
+
+ if (combobox && option) {
+ selectOption(combobox, option);
+ }
+ }
+ });
+
+ const selectOption = (combobox, option) => {
+ const oldSelectedOption = combobox.parentElement.querySelector('[role="listbox"] [role="option"][aria-selected="true"]');
+ const inputElement = combobox.parentElement.querySelector('input[type="hidden"]');
+
+ if (oldSelectedOption != option) {
+ if (oldSelectedOption) {
+ oldSelectedOption.removeAttribute('aria-selected');
+ }
+ option.setAttribute('aria-selected', 'true');
+ }
+ combobox.textContent = option.textContent;
+ if (inputElement.value != option.dataset.value) {
+ inputElement.value = option.dataset.value;
+ inputElement.dispatchEvent(new Event('change', {bubbles: true}));
+ }
+ };
+};
+
/**
* After page load, focus on any element with special autofocus attribute.
*/
@@ -303,6 +441,7 @@ const collapseFix = () => {
export const init = () => {
dropdownFix();
+ comboboxFix();
autoFocus();
tabElementFix();
collapseFix();
diff --git a/theme/boost/scss/moodle/core.scss b/theme/boost/scss/moodle/core.scss
index fec01a5484d..62be8428310 100644
--- a/theme/boost/scss/moodle/core.scss
+++ b/theme/boost/scss/moodle/core.scss
@@ -2369,6 +2369,7 @@ $footer-link-color: $bg-inverse-link-color !default;
width: 100%;
color: $body-color;
}
+ &.active,
&:active,
&:hover,
&:focus,
@@ -2380,13 +2381,14 @@ $footer-link-color: $bg-inverse-link-color !default;
color: $dropdown-link-active-color;
}
}
- &[aria-current="true"] {
+ &[aria-current="true"],
+ &[aria-selected="true"] {
position: relative;
display: flex;
align-items: center;
&:before {
@include fa-icon();
- content: $fa-var-circle;
+ content: $fa-var-check;
position: absolute;
left: 0.4rem;
font-size: 0.7rem;
@@ -2943,3 +2945,22 @@ body.dragging {
width: 9px;
border: 0;
}
+
+.select-menu {
+ ul[role="group"] {
+ padding: 0;
+ margin: 0;
+ li:first-child {
+ cursor: default;
+ font-weight: bold;
+ padding: 0.25rem 1.5rem;
+ display: block;
+ }
+ .dropdown-item {
+ padding-left: 3rem;
+ }
+ }
+ .dropdown-item {
+ cursor: pointer;
+ }
+}
diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css
index 2798375c900..81406a6086c 100644
--- a/theme/boost/style/moodle.css
+++ b/theme/boost/style/moodle.css
@@ -11758,25 +11758,25 @@ ul {
width: 100%;
color: #1d2125; }
-.dropdown-item:active, .dropdown-item:hover, .dropdown-item:focus, .dropdown-item:focus-within {
+.dropdown-item.active, .dropdown-item:active, .dropdown-item:hover, .dropdown-item:focus, .dropdown-item:focus-within {
outline: 0;
background-color: #0f6cbf;
color: #fff; }
- .dropdown-item:active a, .dropdown-item:hover a, .dropdown-item:focus a, .dropdown-item:focus-within a {
+ .dropdown-item.active a, .dropdown-item:active a, .dropdown-item:hover a, .dropdown-item:focus a, .dropdown-item:focus-within a {
color: #fff; }
-.dropdown-item[aria-current="true"] {
+.dropdown-item[aria-current="true"], .dropdown-item[aria-selected="true"] {
position: relative;
display: flex;
align-items: center; }
- .dropdown-item[aria-current="true"]:before {
+ .dropdown-item[aria-current="true"]:before, .dropdown-item[aria-selected="true"]:before {
display: inline-block;
font: normal normal normal 14px/1 FontAwesome;
font-size: inherit;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
- content: "";
+ content: "";
position: absolute;
left: 0.4rem;
font-size: 0.7rem; }
@@ -12260,6 +12260,20 @@ body.dragging .dragging {
width: 9px;
border: 0; }
+.select-menu ul[role="group"] {
+ padding: 0;
+ margin: 0; }
+ .select-menu ul[role="group"] li:first-child {
+ cursor: default;
+ font-weight: bold;
+ padding: 0.25rem 1.5rem;
+ display: block; }
+ .select-menu ul[role="group"] .dropdown-item {
+ padding-left: 3rem; }
+
+.select-menu .dropdown-item {
+ cursor: pointer; }
+
.icon {
font-size: 16px;
width: 16px;
diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css
index 1587a49b8c7..af85d0db5d4 100644
--- a/theme/classic/style/moodle.css
+++ b/theme/classic/style/moodle.css
@@ -11758,25 +11758,25 @@ ul {
width: 100%;
color: #1d2125; }
-.dropdown-item:active, .dropdown-item:hover, .dropdown-item:focus, .dropdown-item:focus-within {
+.dropdown-item.active, .dropdown-item:active, .dropdown-item:hover, .dropdown-item:focus, .dropdown-item:focus-within {
outline: 0;
background-color: #0f6cbf;
color: #fff; }
- .dropdown-item:active a, .dropdown-item:hover a, .dropdown-item:focus a, .dropdown-item:focus-within a {
+ .dropdown-item.active a, .dropdown-item:active a, .dropdown-item:hover a, .dropdown-item:focus a, .dropdown-item:focus-within a {
color: #fff; }
-.dropdown-item[aria-current="true"] {
+.dropdown-item[aria-current="true"], .dropdown-item[aria-selected="true"] {
position: relative;
display: flex;
align-items: center; }
- .dropdown-item[aria-current="true"]:before {
+ .dropdown-item[aria-current="true"]:before, .dropdown-item[aria-selected="true"]:before {
display: inline-block;
font: normal normal normal 14px/1 FontAwesome;
font-size: inherit;
text-rendering: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
- content: "";
+ content: "";
position: absolute;
left: 0.4rem;
font-size: 0.7rem; }
@@ -12260,6 +12260,20 @@ body.dragging .dragging {
width: 9px;
border: 0; }
+.select-menu ul[role="group"] {
+ padding: 0;
+ margin: 0; }
+ .select-menu ul[role="group"] li:first-child {
+ cursor: default;
+ font-weight: bold;
+ padding: 0.25rem 1.5rem;
+ display: block; }
+ .select-menu ul[role="group"] .dropdown-item {
+ padding-left: 3rem; }
+
+.select-menu .dropdown-item {
+ cursor: pointer; }
+
.icon {
font-size: 16px;
width: 16px;