Merge branch 'install_310_STABLE' of https://git.in.moodle.com/amosbot/moodle-install...
authorVíctor Déniz <victor@moodle.com>
Thu, 10 Dec 2020 21:10:40 +0000 (21:10 +0000)
committerVíctor Déniz <victor@moodle.com>
Thu, 10 Dec 2020 21:10:40 +0000 (21:10 +0000)
32 files changed:
admin/tool/behat/tests/behat/edit_permissions.feature
admin/tool/behat/tests/behat/keyboard.feature
course/amd/build/local/activitychooser/dialogue.min.js
course/amd/build/local/activitychooser/dialogue.min.js.map
course/amd/build/local/activitychooser/selectors.min.js
course/amd/build/local/activitychooser/selectors.min.js.map
course/amd/src/local/activitychooser/dialogue.js
course/amd/src/local/activitychooser/selectors.js
enrol/cohort/settings.php
enrol/database/settings.php
enrol/fee/settings.php
enrol/manual/settings.php
enrol/mnet/settings.php
enrol/paypal/settings.php
enrol/self/settings.php
lib/amd/build/permissionmanager.min.js
lib/amd/build/permissionmanager.min.js.map
lib/amd/src/permissionmanager.js
lib/db/upgrade.php
lib/templates/permissionmanager_panelcontent.mustache
lib/templates/permissionmanager_role.mustache
lib/templates/settings_link_page.mustache
mod/assign/module.js
search/classes/manager.php
search/tests/manager_test.php
theme/boost/amd/build/aria.min.js
theme/boost/amd/build/aria.min.js.map
theme/boost/amd/build/loader.min.js
theme/boost/amd/build/loader.min.js.map
theme/boost/amd/src/aria.js
theme/boost/amd/src/loader.js
theme/boost/templates/admin_setting_tabs.mustache

index e3ffc92..7394512 100644 (file)
@@ -6,14 +6,18 @@ Feature: Edit capabilities
 
   Background:
     Given the following "users" exist:
-      | username | firstname | lastname | email |
-      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | username | firstname | lastname  |
+      | teacher1 | Teacher   | 1         |
+      | tutor    | Teaching  | Assistant |
+      | student  | Student   | One       |
     And the following "courses" exist:
-      | fullname | shortname | category |
-      | Course 1 | C1 | 0 |
+      | fullname | shortname |
+      | Course 1 | C1        |
     And the following "course enrolments" exist:
-      | user | course | role |
-      | teacher1 | C1 | editingteacher |
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | tutor    | C1     | teacher        |
+      | student  | C1     | student        |
 
   Scenario: Default system capabilities modification
     Given I log in as "admin"
@@ -60,3 +64,25 @@ Feature: Edit capabilities
     Then "mod/forum:deleteanypost" capability has "Prohibit" permission
     And "mod/forum:editanypost" capability has "Prevent" permission
     And "mod/forum:addquestion" capability has "Allow" permission
+
+  @javascript
+  Scenario: Edit permissions escapes role names correctly
+    When I am on the "C1" "Course" page logged in as "admin"
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Your word for 'Teacher'             | Teacher >= editing  |
+      | Your word for 'Non-editing teacher' | Teacher < "editing" |
+      | Your word for 'Student'             | Studier & 'learner' |
+    And I press "Save and display"
+    And I navigate to course participants
+    Then I should see "Teacher >= editing (Teacher)" in the "Teacher 1" "table_row"
+    And I should see "Teacher < \"editing\" (Non-editing teacher)" in the "Teaching Assistant" "table_row"
+    And I should see "Studier & 'learner' (Student)" in the "Student One" "table_row"
+    And I navigate to "Users > Permissions" in current page administration
+    And I should see "Teacher >= editing" in the "mod/forum:replypost" "table_row"
+    And I should see "Teacher < \"editing\"" in the "mod/forum:replypost" "table_row"
+    And I should see "Studier & 'learner'" in the "mod/forum:replypost" "table_row"
+    And I follow "Prohibit"
+    And "Teacher >= editing" "button" in the "Prohibit role" "dialogue" should be visible
+    And "Teacher < \"editing\"" "button" in the "Prohibit role" "dialogue" should be visible
+    And "Studier & 'learner'" "button" in the "Prohibit role" "dialogue" should be visible
index ca9a74f..cdcb081 100644 (file)
@@ -28,15 +28,16 @@ Feature: Verify that keyboard steps work as expected
     And I press the shift tab key
     And the focused element is "Username" "field"
 
-  @javascript
-  Scenario: Using the arrow keys allows me to navigate through menus
-    Given the following "users" exist:
-      | username | email                        | firstname | lastname |
-      | saffronr | saffron.rutledge@example.com | Saffron   | Rutledge |
-    And I log in as "saffronr"
-    And I click on "Saffron Rutledge" "link" in the ".usermenu" "css_element"
-    When I press the up key
-    Then the focused element is "Log out" "link"
+#  TODO: Uncomment the following when MDL-66979 is integrated.
+#  @javascript
+#  Scenario: Using the arrow keys allows me to navigate through menus
+#    Given the following "users" exist:
+#      | username | email                        | firstname | lastname |
+#      | saffronr | saffron.rutledge@example.com | Saffron   | Rutledge |
+#    And I log in as "saffronr"
+#    And I click on "Saffron Rutledge" "link" in the ".usermenu" "css_element"
+#    When I press the up key
+#    Then the focused element is "Log out" "link"
 
   @javascript
   Scenario: The escape key can be used to close a dialogue
index d517d5b..8028476 100644 (file)
Binary files a/course/amd/build/local/activitychooser/dialogue.min.js and b/course/amd/build/local/activitychooser/dialogue.min.js differ
index 740fe3f..c782a84 100644 (file)
Binary files a/course/amd/build/local/activitychooser/dialogue.min.js.map and b/course/amd/build/local/activitychooser/dialogue.min.js.map differ
index 45f8f4f..ea395d6 100644 (file)
Binary files a/course/amd/build/local/activitychooser/selectors.min.js and b/course/amd/build/local/activitychooser/selectors.min.js differ
index 13f9e9f..25b6d79 100644 (file)
Binary files a/course/amd/build/local/activitychooser/selectors.min.js.map and b/course/amd/build/local/activitychooser/selectors.min.js.map differ
index a3bde76..a7c7018 100644 (file)
@@ -216,7 +216,6 @@ const registerListenerEvents = (modal, mappedModules, partialFavourite, footerDa
         const firstChooserOption = sectionChooserOptions.querySelector(selectors.regions.chooserOption.container);
 
         toggleFocusableChooserOption(firstChooserOption, true);
-        initTabsKeyboardNavigation(body);
         initChooserOptionsKeyboardNavigation(body, mappedModules, sectionChooserOptions, modal);
 
         return body;
@@ -235,77 +234,6 @@ const registerListenerEvents = (modal, mappedModules, partialFavourite, footerDa
     .catch();
 };
 
-/**
- * Initialise the keyboard navigation controls for the tab list items.
- *
- * @method initTabsKeyboardNavigation
- * @param {HTMLElement} body Our modal that we are working with
- */
-const initTabsKeyboardNavigation = (body) => {
-    // Set up the tab handlers.
-    const favTabNav = body.querySelector(selectors.regions.favouriteTabNav);
-    const recommendedTabNav = body.querySelector(selectors.regions.recommendedTabNav);
-    const defaultTabNav = body.querySelector(selectors.regions.defaultTabNav);
-    const activityTabNav = body.querySelector(selectors.regions.activityTabNav);
-    const resourceTabNav = body.querySelector(selectors.regions.resourceTabNav);
-    const tabNavArray = [favTabNav, recommendedTabNav, defaultTabNav, activityTabNav, resourceTabNav];
-    tabNavArray.forEach((element) => {
-        return element.addEventListener('keydown', (e) => {
-            // The first visible navigation tab link.
-            const firstLink = e.target.parentElement.querySelector(selectors.elements.visibletabs);
-            // The last navigation tab link. It would always be the default activities tab link.
-            const lastLink = e.target.parentElement.lastElementChild;
-
-            if (e.keyCode === arrowRight) {
-                const nextLink = e.target.nextElementSibling;
-                if (nextLink === null) {
-                    e.target.tabIndex = -1;
-                    firstLink.tabIndex = 0;
-                    firstLink.focus();
-                } else if (nextLink.classList.contains('d-none')) {
-                    e.target.tabIndex = -1;
-                    lastLink.tabIndex = 0;
-                    lastLink.focus();
-                } else {
-                    e.target.tabIndex = -1;
-                    nextLink.tabIndex = 0;
-                    nextLink.focus();
-                }
-            }
-            if (e.keyCode === arrowLeft) {
-                const previousLink = e.target.previousElementSibling;
-                if (previousLink === null) {
-                    e.target.tabIndex = -1;
-                    lastLink.tabIndex = 0;
-                    lastLink.focus();
-                } else if (previousLink.classList.contains('d-none')) {
-                    e.target.tabIndex = -1;
-                    firstLink.tabIndex = 0;
-                    firstLink.focus();
-                } else {
-                    e.target.tabIndex = -1;
-                    previousLink.tabIndex = 0;
-                    previousLink.focus();
-                }
-            }
-            if (e.keyCode === home) {
-                e.target.tabIndex = -1;
-                firstLink.tabIndex = 0;
-                firstLink.focus();
-            }
-            if (e.keyCode === end) {
-                e.target.tabIndex = -1;
-                lastLink.tabIndex = 0;
-                lastLink.focus();
-            }
-            if (e.keyCode === space) {
-                e.preventDefault();
-                e.target.click();
-            }
-        });
-    });
-};
-
 /**
  * Initialise the keyboard navigation controls for the chooser options.
  *
index dd20e75..34e9adb 100644 (file)
@@ -52,10 +52,8 @@ export default {
         help: getDataSelector('region', 'help'),
         modules: getDataSelector('region', 'modules'),
         favouriteTabNav: getDataSelector('region', 'favourite-tab-nav'),
-        recommendedTabNav: getDataSelector('region', 'recommended-tab-nav'),
         defaultTabNav: getDataSelector('region', 'default-tab-nav'),
         activityTabNav: getDataSelector('region', 'activity-tab-nav'),
-        resourceTabNav: getDataSelector('region', 'resources-tab-nav'),
         favouriteTab: getDataSelector('region', 'favourites'),
         recommendedTab: getDataSelector('region', 'recommended'),
         defaultTab: getDataSelector('region', 'default'),
index 8c08a74..50cd5dc 100644 (file)
@@ -36,7 +36,7 @@ if ($ADMIN->fulltree) {
         $student = get_archetype_roles('student');
         $student = reset($student);
         $settings->add(new admin_setting_configselect('enrol_cohort/roleid',
-            get_string('defaultrole', 'role'), '', $student->id, $options));
+            get_string('defaultrole', 'role'), '', $student->id ?? null, $options));
 
         $options = array(
             ENROL_EXT_REMOVED_UNENROL        => get_string('extremovedunenrol', 'enrol'),
index 28c7504..92d0833 100644 (file)
@@ -86,7 +86,11 @@ if ($ADMIN->fulltree) {
         $options = get_default_enrol_roles(context_system::instance());
         $student = get_archetype_roles('student');
         $student = reset($student);
-        $settings->add(new admin_setting_configselect('enrol_database/defaultrole', get_string('defaultrole', 'enrol_database'), get_string('defaultrole_desc', 'enrol_database'), $student->id, $options));
+        $settings->add(new admin_setting_configselect('enrol_database/defaultrole',
+            get_string('defaultrole', 'enrol_database'),
+            get_string('defaultrole_desc', 'enrol_database'),
+            $student->id ?? null,
+            $options));
     }
 
     $settings->add(new admin_setting_configcheckbox('enrol_database/ignorehiddencourses', get_string('ignorehiddencourses', 'enrol_database'), get_string('ignorehiddencourses_desc', 'enrol_database'), 0));
index 2f383b8..50d919e 100644 (file)
@@ -71,7 +71,7 @@ if ($ADMIN->fulltree) {
         $student = get_archetype_roles('student');
         $student = reset($student);
         $settings->add(new admin_setting_configselect('enrol_fee/roleid',
-            get_string('defaultrole', 'enrol_fee'), get_string('defaultrole_desc', 'enrol_fee'), $student->id, $options));
+            get_string('defaultrole', 'enrol_fee'), get_string('defaultrole_desc', 'enrol_fee'), $student->id ?? null, $options));
     }
 
     $settings->add(new admin_setting_configduration('enrol_fee/enrolperiod',
index 2056c19..8dcabcc 100644 (file)
@@ -63,7 +63,7 @@ if ($ADMIN->fulltree) {
         $student = get_archetype_roles('student');
         $student = reset($student);
         $settings->add(new admin_setting_configselect('enrol_manual/roleid',
-            get_string('defaultrole', 'role'), '', $student->id, $options));
+            get_string('defaultrole', 'role'), '', $student->id ?? null, $options));
     }
 
     $options = array(2 => get_string('coursestart'), 3 => get_string('today'), 4 => get_string('now', 'enrol_manual'));
index fc7517f..c74c8e1 100644 (file)
@@ -36,6 +36,6 @@ if ($ADMIN->fulltree) {
         $student = reset($student);
         $settings->add(new admin_setting_configselect_with_advanced('enrol_mnet/roleid',
             get_string('defaultrole', 'role'), '',
-            array('value'=>$student->id, 'adv'=>true), $options));
+            array('value' => $student->id ?? null, 'adv' => true), $options));
     }
 }
index 66e0ae4..d9a3a7f 100644 (file)
@@ -66,7 +66,10 @@ if ($ADMIN->fulltree) {
         $student = get_archetype_roles('student');
         $student = reset($student);
         $settings->add(new admin_setting_configselect('enrol_paypal/roleid',
-            get_string('defaultrole', 'enrol_paypal'), get_string('defaultrole_desc', 'enrol_paypal'), $student->id, $options));
+            get_string('defaultrole', 'enrol_paypal'),
+            get_string('defaultrole_desc', 'enrol_paypal'),
+            $student->id ?? null,
+            $options));
     }
 
     $settings->add(new admin_setting_configduration('enrol_paypal/enrolperiod',
index 6375ea2..cee5d7f 100644 (file)
@@ -79,7 +79,10 @@ if ($ADMIN->fulltree) {
         $student = get_archetype_roles('student');
         $student = reset($student);
         $settings->add(new admin_setting_configselect('enrol_self/roleid',
-            get_string('defaultrole', 'enrol_self'), get_string('defaultrole_desc', 'enrol_self'), $student->id, $options));
+            get_string('defaultrole', 'enrol_self'),
+            get_string('defaultrole_desc', 'enrol_self'),
+            $student->id ?? null,
+            $options));
     }
 
     $settings->add(new admin_setting_configduration('enrol_self/enrolperiod',
index f53bd41..3e20015 100644 (file)
Binary files a/lib/amd/build/permissionmanager.min.js and b/lib/amd/build/permissionmanager.min.js differ
index 8413c76..63e284b 100644 (file)
Binary files a/lib/amd/build/permissionmanager.min.js.map and b/lib/amd/build/permissionmanager.min.js.map differ
index 0772104..3f350d3 100644 (file)
@@ -204,7 +204,7 @@ define(['jquery', 'core/config', 'core/notification', 'core/templates', 'core/yu
                 .done(function(content) {
                     panel.set('bodyContent', content);
                     panel.show();
-                    $('div.role_buttons').on('click', 'input', function(e) {
+                    $('div.role_buttons').on('click', 'button', function(e) {
                         var roleid = $(e.currentTarget).data('role-id');
                         changePermissions(row, roleid, action);
                     });
index 8c46237..25e5433 100644 (file)
@@ -2786,11 +2786,12 @@ function xmldb_main_upgrade($oldversion) {
     if ($oldversion < 2020101600.01) {
         // Delete orphaned course_modules_completion rows; these were not deleted properly
         // by remove_course_contents function.
-        $DB->delete_records_subquery('course_modules_completion', 'id', 'id',
-               "SELECT cmc.id
-                  FROM {course_modules_completion} cmc
-             LEFT JOIN {course_modules} cm ON cm.id = cmc.coursemoduleid
-                 WHERE cm.id IS NULL");
+        $DB->delete_records_select('course_modules_completion', "
+                NOT EXISTS (
+                        SELECT 1
+                          FROM {course_modules} cm
+                         WHERE cm.id = {course_modules_completion}.coursemoduleid
+                )");
         upgrade_main_savepoint(true, 2020101600.01);
     }
 
index dc74d24..da3825c 100644 (file)
 
     Context variables required for this template:
     * confirmation Confirmation text
-    * roles array of role details
+    * roles array of role details. Note: in this array, rolename must have been
+          prepared for output with format_string, or more likely one of the role API functions like role_fix_names.
 
     Example context (json):
     { "message": "Do you really want to remove Non-editing teacher from the list of allowed roles for capability View added and updated modules in recent activity block?",
-      "roles": [{"roleid": 1, "rolename": "manager", "disabled":"disabled"}]}
+      "roles": [{"roleid": 1, "rolename": "Manager", "disabled": "disabled"}]}
 }}
 <div class="popup_content" style="text-align:center;">
     {{message}} <hr/>
     <div class="role_buttons">
         {{#roles}}
-            <input type="button" value="{{rolename}}" class="btn btn-secondary mb-1" data-role-id="{{roleid}}" {{disabled}}/>
+            <button type="button" class="btn btn-secondary mb-1" data-role-id="{{roleid}}" {{disabled}}>{{{rolename}}}</button>
         {{/roles}}
     </div>
 </div>
index d2a1652..b308653 100644 (file)
@@ -27,7 +27,8 @@
     * action
 
     Context variables required for this template:
-    * rolename Name of the role rendered
+    * rolename Name of the role rendered - must have been prepared for output with format_string,
+                                           or more likely one of the role API functions like role_fix_names.
     * roleid Id of the role
     * action WEhich action is done on click
     * spanclass class attribute of span
@@ -43,7 +44,7 @@
      "linkclass": "preventlink",
      "adminurl" : "http://localhost/moodle/admin/"}
 }}
-<span style="display:inline-block;" class="{{spanclass}}">&nbsp;{{rolename}}&nbsp;
+<span style="display:inline-block;" class="{{spanclass}}">&nbsp;{{{rolename}}}&nbsp;
     <a href="{{adminurl}}roles/permissions.php" class="{{linkclass}}" data-role-id="{{roleid}}" data-action="{{action}}">
         {{#icon}}
             {{#pix}}{{icon}}, core, {{iconalt}}{{/pix}}
index 2991753..07f0705 100644 (file)
@@ -40,7 +40,7 @@
 <ul class="nav nav-tabs" role="tablist">
 <!-- First the top most node and immediate children -->
     <li class="nav-item">
-        <a class="nav-link active" href="#link{{node.key}}" data-toggle="tab" role="tab">{{node.text}}</a>
+        <a class="nav-link active" href="#link{{node.key}}" data-toggle="tab" role="tab" aria-selected="true">{{node.text}}</a>
     </li>
 <!-- Now the first level children with sub nodes -->
 {{#node.children}}
@@ -48,7 +48,7 @@
         {{#display}}
             {{^is_short_branch}}
                 <li class="nav-item">
-                    <a class="nav-link" href="#link{{key}}" data-toggle="tab" role="tab">{{text}}</a>
+                    <a class="nav-link" href="#link{{key}}" data-toggle="tab" role="tab" aria-selected="false" tabindex="-1">{{text}}</a>
                 </li>
             {{/is_short_branch}}
         {{/display}}
index 111003b..9b7ad6b 100644 (file)
@@ -76,6 +76,7 @@ M.mod_assign.init_grading_table = function(Y) {
         var batchform = Y.one('form.gradingbatchoperationsform');
         if (batchform) {
             batchform.on('submit', function(e) {
+                M.util.js_pending('mod_assign/module.js:batch:submit');
                 checkboxes = Y.all('td.c0 input');
                 var selectedusers = [];
                 checkboxes.each(function(node) {
@@ -102,8 +103,10 @@ M.mod_assign.init_grading_table = function(Y) {
                         confirmmessage = M.util.get_string('batchoperationconfirm' + operation.get('value'), 'assign');
                     }
                     if (!confirm(confirmmessage)) {
+                        M.util.js_complete('mod_assign/module.js:batch:submit');
                         e.preventDefault();
                     }
+                    // Note: Do not js_complete. The page being reloaded will empty it.
                 }
             });
         }
index b13f152..c0a1c5f 100644 (file)
@@ -823,8 +823,8 @@ class manager {
             }
         }
 
-        // Add all supported block contexts, in a single query for performance.
-        if (!empty($areasbylevel[CONTEXT_BLOCK])) {
+        // Add all supported block contexts for course contexts that user can access, in a single query for performance.
+        if (!empty($areasbylevel[CONTEXT_BLOCK]) && !empty($coursecontextids)) {
             // Get list of all block types we care about.
             $blocklist = [];
             foreach ($areasbylevel[CONTEXT_BLOCK] as $areaid => $searchclass) {
index 0a52f5f..217dcb4 100644 (file)
@@ -791,7 +791,6 @@ class search_manager_testcase extends advanced_testcase {
         $this->assertEquals($contexts['block_html-content'], $limitedcontexts['block_html-content']);
 
         // Get block context ids for the blocks that appear.
-        global $DB;
         $blockcontextids = $DB->get_fieldset_sql('
             SELECT x.id
               FROM {block_instances} bi
@@ -811,6 +810,43 @@ class search_manager_testcase extends advanced_testcase {
         $this->assertCount(1, $contexts['block_html-content']);
     }
 
+    /**
+     * Tests retrieval of users search areas when limiting to a course the user is not enrolled in
+     */
+    public function test_search_users_accesses_limit_non_enrolled_course() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        $search = testable_core_search::instance();
+        $search->add_core_search_areas();
+
+        $course = $this->getDataGenerator()->create_course();
+        $context = context_course::instance($course->id);
+
+        // Limit courses to search to only those the user is enrolled in.
+        set_config('searchallavailablecourses', 0);
+
+        $usercontexts = $search->get_areas_user_accesses([$course->id])->usercontexts;
+        $this->assertNotEmpty($usercontexts);
+        $this->assertArrayNotHasKey('core_course-course', $usercontexts);
+
+        // This config ensures the search will also include courses the user can view.
+        set_config('searchallavailablecourses', 1);
+
+        // Allow "Authenticated user" role to view the course without being enrolled in it.
+        $userrole = $DB->get_record('role', ['shortname' => 'user'], '*', MUST_EXIST);
+        role_change_permission($userrole->id, $context, 'moodle/course:view', CAP_ALLOW);
+
+        $usercontexts = $search->get_areas_user_accesses([$course->id])->usercontexts;
+        $this->assertNotEmpty($usercontexts);
+        $this->assertArrayHasKey('core_course-course', $usercontexts);
+        $this->assertEquals($context->id, reset($usercontexts['core_course-course']));
+    }
+
     /**
      * Test get_areas_user_accesses with regard to the 'all available courses' config option.
      *
index 447fd66..f3154ce 100644 (file)
Binary files a/theme/boost/amd/build/aria.min.js and b/theme/boost/amd/build/aria.min.js differ
index 2098af4..9878545 100644 (file)
Binary files a/theme/boost/amd/build/aria.min.js.map and b/theme/boost/amd/build/aria.min.js.map differ
index baacba5..4f9b804 100644 (file)
Binary files a/theme/boost/amd/build/loader.min.js and b/theme/boost/amd/build/loader.min.js differ
index bcde39c..f9e4a1b 100644 (file)
Binary files a/theme/boost/amd/build/loader.min.js.map and b/theme/boost/amd/build/loader.min.js.map differ
index b2b96b0..1acf867 100644 (file)
  * @copyright  2018 Damyon Wiese <damyon@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/pending'], function($, Pending) {
-    return {
-        init: function() {
-            // Drop downs from bootstrap don't support keyboard accessibility by default.
-            var focusEnd = false,
-                setFocusEnd = function() {
-                    focusEnd = true;
-                },
-                getFocusEnd = function() {
-                    var result = focusEnd;
-                    focusEnd = false;
-                    return result;
-                };
-
-            // Special handling for "up" keyboard control.
-            $('[data-toggle="dropdown"]').keydown(function(e) {
-                var trigger = e.which || e.keyCode,
-                    expanded;
-
-                // Up key opens the menu at the end.
-                if (trigger == 38) {
-                    // Focus the end of the menu, not the beginning.
-                    setFocusEnd();
-                }
 
-                // Escape key only closes the menu, it doesn't open it.
-                if (trigger == 27) {
-                    expanded = $(e.target).attr('aria-expanded');
-                    e.preventDefault();
-                    if (expanded == "false") {
-                        $(e.target).click();
-                    }
-                }
+import $ from 'jquery';
+import Pending from 'core/pending';
 
-                // Space key or Enter key opens the menu.
-                if (trigger == 32 || trigger == 13) {
-                    // Cancel random scroll.
-                    e.preventDefault();
-                    // Open the menu instead.
-                    $(e.target).click();
-                }
-            });
+/**
+ * Drop downs from bootstrap don't support keyboard accessibility by default.
+ */
+const dropdownFix = () => {
+    let focusEnd = false;
+    const setFocusEnd = () => {
+        focusEnd = true;
+    };
+    const getFocusEnd = () => {
+        const result = focusEnd;
+        focusEnd = false;
+        return result;
+    };
+
+    // Special handling for "up" keyboard control.
+    document.addEventListener('keydown', e => {
+        if (e.target.matches('[data-toggle="dropdown"]')) {
+            const trigger = e.key;
+
+            // Up key opens the menu at the end.
+            if (trigger == 'ArrowUp') {
+                // Focus the end of the menu, not the beginning.
+                setFocusEnd();
+            }
+
+            // Space key or Enter key opens the menu.
+            if (trigger == ' ' || trigger == 'Enter') {
+                // Cancel random scroll.
+                e.preventDefault();
+                // Open the menu instead.
+                e.target.click();
+            }
+        }
+    });
+
+    // Special handling for navigation keys when menu is open.
+    const shiftFocus = element => {
+        const delayedFocus = pendingPromise => {
+            element.focus();
+            pendingPromise.resolve();
+        };
+        setTimeout(delayedFocus, 50, new Pending('core/aria:delayed-focus'));
+    };
+
+    $('.dropdown').on('shown.bs.dropdown', e => {
+        // We need to focus on the first menuitem.
+        const menu = e.target.querySelector('[role="menu"]');
+        let menuItems = false;
+        let foundMenuItem = false;
 
-            // Special handling for navigation keys when menu is open.
-            var shiftFocus = function(element) {
-                var delayedFocus = function(pendingPromise) {
-                    $(this).focus();
-                    pendingPromise.resolve();
-                }.bind(element);
-                setTimeout(delayedFocus, 50, new Pending('core/aria:delayed-focus'));
-            };
-
-            $('.dropdown').on('shown.bs.dropdown', function(e) {
-                // We need to focus on the first menuitem.
-                var menu = $(e.target).find('[role="menu"]'),
-                    menuItems = false,
-                    foundMenuItem = false;
-
-                if (menu) {
-                    menuItems = $(menu).find('[role="menuitem"]');
+        if (menu) {
+            menuItems = menu.querySelectorAll('[role="menuitem"]');
+        }
+        if (menuItems && menuItems.length > 0) {
+            if (getFocusEnd()) {
+                foundMenuItem = menuItems[menuItems.length - 1];
+            } else {
+                // The first menu entry, pretty reasonable.
+                foundMenuItem = menuItems[0];
+            }
+        }
+        if (foundMenuItem) {
+            shiftFocus(foundMenuItem);
+        }
+    });
+    // Search for menu items by finding the first item that has
+    // text starting with the typed character (case insensitive).
+    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 < menuItems.length; i++) {
+                const item = menuItems[i];
+                const itemText = item.text.trim().toLowerCase();
+                if (itemText.indexOf(trigger) == 0) {
+                    shiftFocus(item);
+                    break;
                 }
-                if (menuItems && menuItems.length > 0) {
-                    if (getFocusEnd()) {
-                        foundMenuItem = menuItems[menuItems.length - 1];
-                    } else {
-                        // The first menu entry, pretty reasonable.
-                        foundMenuItem = menuItems[0];
+            }
+        }
+    });
+
+    // Keyboard navigation for arrow keys, home and end keys.
+    document.addEventListener('keydown', e => {
+        if (e.target.matches('.dropdown [role="menu"] [role="menuitem"]')) {
+            const trigger = e.key;
+            let next = false;
+            const menu = e.target.closest('[role="menu"]');
+
+            if (!menu) {
+                return;
+            }
+            const menuItems = menu.querySelectorAll('[role="menuitem"]');
+            if (!menuItems) {
+                return;
+            }
+            // Down key.
+            if (trigger == 'ArrowDown') {
+                for (let i = 0; i < menuItems.length - 1; i++) {
+                    if (menuItems[i] == e.target) {
+                        next = menuItems[i + 1];
+                        break;
                     }
                 }
-                if (foundMenuItem) {
-                    shiftFocus(foundMenuItem);
-                }
-            });
-            // Search for menu items by finding the first item that has
-            // text starting with the typed character (case insensitive).
-            $('.dropdown [role="menu"] [role="menuitem"]').keypress(function(e) {
-                var trigger = String.fromCharCode(e.which || e.keyCode),
-                    menu = $(e.target).closest('[role="menu"]'),
-                    i = 0,
-                    menuItems = false,
-                    item,
-                    itemText;
-
-                if (!menu) {
-                    return;
-                }
-                menuItems = $(menu).find('[role="menuitem"]');
-                if (!menuItems) {
-                    return;
+                if (!next) {
+                    // Wrap to first item.
+                    next = menuItems[0];
                 }
 
-                trigger = trigger.toLowerCase();
-                for (i = 0; i < menuItems.length; i++) {
-                    item = $(menuItems[i]);
-                    itemText = item.text().trim().toLowerCase();
-                    if (itemText.indexOf(trigger) == 0) {
-                        shiftFocus(item);
+            } else if (trigger == 'ArrowUp') {
+                // Up key.
+                for (let i = 1; i < menuItems.length; i++) {
+                    if (menuItems[i] == e.target) {
+                        next = menuItems[i - 1];
                         break;
                     }
                 }
-            });
-
-            // Keyboard navigation for arrow keys, home and end keys.
-            $('.dropdown [role="menu"] [role="menuitem"]').keydown(function(e) {
-                var trigger = e.which || e.keyCode,
-                    next = false,
-                    menu = $(e.target).closest('[role="menu"]'),
-                    i = 0,
-                    menuItems = false;
-                if (!menu) {
-                    return;
-                }
-                menuItems = $(menu).find('[role="menuitem"]');
-                if (!menuItems) {
-                    return;
+                if (!next) {
+                    // Wrap to last item.
+                    next = menuItems[menuItems.length - 1];
                 }
-                // Down key.
-                if (trigger == 40) {
-                    for (i = 0; i < menuItems.length - 1; i++) {
-                        if (menuItems[i] == e.target) {
-                            next = menuItems[i + 1];
-                            break;
-                        }
-                    }
-                    if (!next) {
-                        // Wrap to first item.
-                        next = menuItems[0];
-                    }
 
-                } else if (trigger == 38) {
-                    // Up key.
-                    for (i = 1; i < menuItems.length; i++) {
-                        if (menuItems[i] == e.target) {
-                            next = menuItems[i - 1];
-                            break;
-                        }
-                    }
-                    if (!next) {
-                        // Wrap to last item.
-                        next = menuItems[menuItems.length - 1];
-                    }
+            } else if (trigger == 'Home') {
+                // Home key.
+                next = menuItems[0];
 
-                } else if (trigger == 36) {
-                    // Home key.
-                    next = menuItems[0];
+            } else if (trigger == 'End') {
+                // End key.
+                next = menuItems[menuItems.length - 1];
+            }
+            // Variable next is set if we do want to act on the keypress.
+            if (next) {
+                e.preventDefault();
+                shiftFocus(next);
+            }
+            return;
+        }
+    });
 
-                } else if (trigger == 35) {
-                    // End key.
-                    next = menuItems[menuItems.length - 1];
-                }
-                // Variable next is set if we do want to act on the keypress.
-                if (next) {
-                    e.preventDefault();
-                    shiftFocus(next);
-                }
-                return;
-            });
-            $('.dropdown').on('hidden.bs.dropdown', function(e) {
-                // We need to focus on the menu trigger.
-                var trigger = $(e.target).find('[data-toggle="dropdown"]');
-                if (trigger) {
-                    shiftFocus(trigger);
-                }
+    $('.dropdown').on('hidden.bs.dropdown', e => {
+        // We need to focus on the menu trigger.
+        const trigger = e.target.querySelector('[data-toggle="dropdown"]');
+        if (trigger) {
+            shiftFocus(trigger);
+        }
+    });
+};
+
+/**
+ * After page load, focus on any element with special autofocus attribute.
+ */
+const autoFocus = () => {
+    window.addEventListener("load", () => {
+        const alerts = document.querySelectorAll('[data-aria-autofocus="true"][role="alert"]');
+        Array.prototype.forEach.call(alerts, autofocusElement => {
+            // According to the specification an role="alert" region is only read out on change to the content
+            // of that region.
+            autofocusElement.innerHTML += ' ';
+            autofocusElement.removeAttribute('data-aria-autofocus');
+        });
+    });
+};
+
+/**
+ * Changes the focus to the correct tab based on the key that is pressed.
+ * @param {KeyboardEvent} e
+ */
+const updateTabFocus = e => {
+    const tabList = e.target.closest('[role="tablist"]');
+    const vertical = tabList.getAttribute('aria-orientation') == 'vertical';
+    const rtl = window.right_to_left();
+    const arrowNext = vertical ? 'ArrowDown' : (rtl ? 'ArrowLeft' : 'ArrowRight');
+    const arrowPrevious = vertical ? 'ArrowUp' : (rtl ? 'ArrowRight' : 'ArrowLeft');
+    const tabs = Array.prototype.filter.call(
+        tabList.querySelectorAll('[role="tab"]'),
+        tab => getComputedStyle(tab).display !== 'none'); // We only work with the visible tabs.
+
+    for (let i = 0; i < tabs.length; i++) {
+        tabs[i].index = i;
+    }
+
+    switch (e.key) {
+        case arrowNext:
+            e.preventDefault();
+            if (e.target.index !== undefined && tabs[e.target.index + 1]) {
+                tabs[e.target.index + 1].focus();
+            } else {
+                tabs[0].focus();
+            }
+            break;
+        case arrowPrevious:
+            e.preventDefault();
+            if (e.target.index !== undefined && tabs[e.target.index - 1]) {
+                tabs[e.target.index - 1].focus();
+            } else {
+                tabs[tabs.length - 1].focus();
+            }
+            break;
+        case 'Home':
+            e.preventDefault();
+            tabs[0].focus();
+            break;
+        case 'End':
+            e.preventDefault();
+            tabs[tabs.length - 1].focus();
+            break;
+        case 'Enter':
+        case ' ':
+            e.preventDefault();
+            $(e.target).tab('show');
+            tabs.forEach(tab => {
+                tab.tabIndex = -1;
             });
+            e.target.tabIndex = 0;
+    }
+};
+
+/**
+ * Fix accessibility issues regarding tab elements focus and their tab order in Bootstrap navs.
+ */
+const tabElementFix = () => {
+    document.addEventListener('keydown', e => {
+        if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'Enter', ' '].includes(e.key)) {
+            if (e.target.matches('[role="tablist"] [role="tab"]')) {
+                updateTabFocus(e);
+            }
+        }
+    });
 
-            // After page load, focus on any element with special autofocus attribute.
-            window.addEventListener("load", () => {
-                const alerts = document.querySelectorAll('[data-aria-autofocus="true"][role="alert"]');
-                Array.prototype.forEach.call(alerts, autofocusElement => {
-                    // According to the specification an role="alert" region is only read out on change to the content
-                    // of that region.
-                    autofocusElement.innerHTML += ' ';
-                    autofocusElement.removeAttribute('data-aria-autofocus');
-                });
+    document.addEventListener('click', e => {
+        if (e.target.matches('[role="tablist"] [role="tab"]')) {
+            const tabs = e.target.closest('[role="tablist"]').querySelectorAll('[role="tab"]');
+            e.preventDefault();
+            $(e.target).tab('show');
+            tabs.forEach(tab => {
+                tab.tabIndex = -1;
             });
+            e.target.tabIndex = 0;
         }
-    };
-});
+    });
+};
+
+export const init = () => {
+    dropdownFix();
+    autoFocus();
+    tabElementFix();
+};
index a050c6f..f92a379 100644 (file)
@@ -25,7 +25,7 @@
  */
 
 import $ from 'jquery';
-import Aria from './aria';
+import * as Aria from './aria';
 import Bootstrap from './bootstrap/index';
 import Pending from 'core/pending';
 import Scroll from './scroll';
@@ -43,9 +43,12 @@ const rememberTabs = () => {
             location.hash = hash;
         }
     });
-    var hash = window.location.hash;
+    const hash = window.location.hash;
     if (hash) {
-       $('.nav-link[href="' + hash + '"]').tab('show');
+        const tab = document.querySelector('.nav-link[href="' + hash + '"]');
+        if (tab) {
+            tab.click();
+        }
     }
 };
 
@@ -83,6 +86,9 @@ const pendingPromise = new Pending('theme_boost/loader:init');
 // Add pending promise event listeners to relevant Bootstrap custom events.
 setupBootstrapPendingChecks();
 
+// Setup Aria helpers for Bootstrap features.
+Aria.init();
+
 // Remember the last visited tabs.
 rememberTabs();
 
@@ -98,9 +104,6 @@ enableTooltips();
 // Disables flipping the dropdowns up and getting hidden behind the navbar.
 $.fn.dropdown.Constructor.Default.flip = false;
 
-// Setup Aria helpers for Bootstrap features.
-Aria.init();
-
 pendingPromise.resolve();
 
 export {
index ec25e9f..162314a 100644 (file)
@@ -39,7 +39,9 @@
 <ul class="nav nav-tabs" role="tablist">
     {{#tabs}}
         <li class="nav-item">
-            <a href="#{{name}}" class="nav-link {{#active}}active{{/active}}" data-toggle="tab" role="tab">{{displayname}}</a>
+            <a href="#{{name}}" class="nav-link {{#active}}active{{/active}}" data-toggle="tab" role="tab"
+                {{#active}}aria-selected="true"{{/active}}
+                {{^active}}aria-selected="false" tabindex="-1"{{/active}}>{{displayname}}</a>
         </li>
     {{/tabs}}
 </ul>