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}} + {{#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}} + {{#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;