diff --git a/lang/en/moodle.php b/lang/en/moodle.php
index 6fbf005401e..f7e08d645e4 100644
--- a/lang/en/moodle.php
+++ b/lang/en/moodle.php
@@ -26,6 +26,7 @@ $string['abouttobeinstalled'] = 'about to be installed';
$string['action'] = 'Action';
$string['actionchoice'] = 'What do you want to do with the file \'{$a}\'?';
$string['actions'] = 'Actions';
+$string['actionsmenu'] = 'Actions menu';
$string['active'] = 'Active';
$string['activeusers'] = 'Active users';
$string['activities'] = 'Activities';
diff --git a/lib/outputcomponents.php b/lib/outputcomponents.php
index d4850dab21b..d5f45acb9c3 100644
--- a/lib/outputcomponents.php
+++ b/lib/outputcomponents.php
@@ -4293,7 +4293,7 @@ class action_menu implements renderable, templatable {
$pixicon = '';
$linkclasses[] = 'textmenu';
} else {
- $title = new lang_string('actions', 'moodle');
+ $title = new lang_string('actionsmenu', 'moodle');
$this->actionicon = new pix_icon(
't/edit_menu',
'',
@@ -4482,8 +4482,9 @@ class action_menu implements renderable, templatable {
$primary->menutrigger = $this->menutrigger;
$primary->triggerextraclasses = $this->triggerextraclasses;
} else {
- $primary->title = get_string('actions');
- $actionicon = new pix_icon('t/edit_menu', '', 'moodle', ['class' => 'iconsmall actionmenu', 'title' => '']);
+ $primary->title = get_string('actionsmenu');
+ $iconattributes = ['class' => 'iconsmall actionmenu', 'title' => $primary->title];
+ $actionicon = new pix_icon('t/edit_menu', '', 'moodle', $iconattributes);
}
if ($actionicon instanceof pix_icon) {
diff --git a/lib/templates/action_menu_link.mustache b/lib/templates/action_menu_link.mustache
index cec09b71261..6aae3b80a5c 100644
--- a/lib/templates/action_menu_link.mustache
+++ b/lib/templates/action_menu_link.mustache
@@ -32,5 +32,5 @@
{{#icon}}{{#pix}}{{key}}, {{component}}, {{title}}{{/pix}}{{/icon}}{{#showtext}}{{/showtext}}
{{/disabled}}
{{#disabled}}
- {{#icon}}{{#pix}}{{key}}, {{component}}, {{title}}{{/pix}}{{/icon}}{{{text}}}
+ {{#icon}}{{#pix}}{{key}}, {{component}}, {{title}}{{/pix}}{{/icon}}{{{text}}}
{{/disabled}}
diff --git a/lib/templates/action_menu_trigger.mustache b/lib/templates/action_menu_trigger.mustache
index fce1a8b7e1f..bab31932683 100644
--- a/lib/templates/action_menu_trigger.mustache
+++ b/lib/templates/action_menu_trigger.mustache
@@ -27,4 +27,4 @@
"triggerextraclasses": ""
}
}}
-
+
diff --git a/lib/tests/user_menu_test.php b/lib/tests/user_menu_test.php
index 647d4408ec5..175be4c3990 100644
--- a/lib/tests/user_menu_test.php
+++ b/lib/tests/user_menu_test.php
@@ -86,6 +86,8 @@ test
$this->setAdminUser();
$PAGE->set_url('/');
$CFG->theme = 'clean';
+ $PAGE->reset_theme_and_output();
+ $PAGE->initialise_theme_and_output();
// Set the configuration.
set_config('customusermenuitems', $data);
@@ -94,7 +96,8 @@ test
$dividercount += 2;
// The basic entry count will additionally include the wrapper menu, Dashboard, Profile, Logout and switch roles link.
- $entrycount += 4;
+ // On clean theme only, the trigger is also a menuitem.
+ $entrycount += 5;
$output = $OUTPUT->user_menu($USER);
preg_match_all('/]+role="menuitem"[^>]+>/', $output, $results);
diff --git a/theme/boost/amd/build/aria.min.js b/theme/boost/amd/build/aria.min.js
new file mode 100644
index 00000000000..37d107578f3
--- /dev/null
+++ b/theme/boost/amd/build/aria.min.js
@@ -0,0 +1 @@
+define(["jquery"],function(a){return{init:function(){var b=!1,c=function(){b=!0},d=function(){var a=b;return b=!1,a};a('[data-toggle="dropdown"]').keydown(function(b){var d,e=b.which||b.keyCode;38==e&&c(),27==e&&(d=a(b.target).attr("aria-expanded"),b.preventDefault(),"false"==d&&a(b.target).click()),32!=e&&13!=e||(b.preventDefault(),a(b.target).click())});var e=function(b){var c=function(){a(this).focus()}.bind(b);setTimeout(c,50)};a(".dropdown").on("shown.bs.dropdown",function(b){var c=a(b.target).find('[role="menu"]'),f=!1,g=!1;c&&(f=a(c).find('[role="menuitem"]')),f&&f.length>0&&(g=d()?f[f.length-1]:f[0]),g&&e(g)}),a('.dropdown [role="menu"] [role="menuitem"]').keypress(function(b){var c,d,f=String.fromCharCode(b.which||b.keyCode),g=a(b.target).closest('[role="menu"]'),h=0,i=!1;if(g&&(i=a(g).find('[role="menuitem"]')))for(f=f.toLowerCase(),h=0;h.
+
+/**
+ * Enhancements to Bootstrap components for accessibility.
+ *
+ * @module theme_boost/aria
+ * @copyright 2018 Damyon Wiese
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery'], function($) {
+ 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();
+ }
+ }
+
+ // 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();
+ }
+ });
+
+ // Special handling for navigation keys when menu is open.
+ var shiftFocus = function(element) {
+ var delayedFocus = function() {
+ $(this).focus();
+ }.bind(element);
+ setTimeout(delayedFocus, 50);
+ };
+
+ $('.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 (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).
+ $('.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;
+ }
+
+ 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);
+ 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;
+ }
+ // 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 == 36) {
+ // Home key.
+ next = menuItems[0];
+
+ } 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);
+ }
+ });
+ }
+ };
+});
diff --git a/theme/boost/amd/src/loader.js b/theme/boost/amd/src/loader.js
index bfd6d50fde5..8ab1706af09 100644
--- a/theme/boost/amd/src/loader.js
+++ b/theme/boost/amd/src/loader.js
@@ -28,7 +28,8 @@ define(['jquery', './tether', 'core/event'], function(jQuery, Tether, Event) {
window.jQuery = jQuery;
window.Tether = Tether;
- require(['theme_boost/util',
+ require(['theme_boost/aria',
+ 'theme_boost/util',
'theme_boost/alert',
'theme_boost/button',
'theme_boost/carousel',
@@ -39,7 +40,7 @@ define(['jquery', './tether', 'core/event'], function(jQuery, Tether, Event) {
'theme_boost/tab',
'theme_boost/tooltip',
'theme_boost/popover'],
- function() {
+ function(Aria) {
// We do twice because: https://github.com/twbs/bootstrap/issues/10547
jQuery('body').popover({
@@ -63,8 +64,11 @@ define(['jquery', './tether', 'core/event'], function(jQuery, Tether, Event) {
selector: '[data-toggle="popover"]',
trigger: 'focus'
});
+
});
});
+
+ Aria.init();
});
diff --git a/theme/boost/templates/core/action_menu.mustache b/theme/boost/templates/core/action_menu.mustache
index c6878eab060..ab38ebab653 100644
--- a/theme/boost/templates/core/action_menu.mustache
+++ b/theme/boost/templates/core/action_menu.mustache
@@ -52,4 +52,4 @@
{{/primary}}
-
\ No newline at end of file
+
diff --git a/theme/boost/templates/core/action_menu_trigger.mustache b/theme/boost/templates/core/action_menu_trigger.mustache
index e53f52bfa20..be8707e42f4 100644
--- a/theme/boost/templates/core/action_menu_trigger.mustache
+++ b/theme/boost/templates/core/action_menu_trigger.mustache
@@ -78,7 +78,7 @@
}
}}
-
{{#secondary}}
-