From 191a1c7c23e90ad0bdbaf73ae1c4103ef59b2cd2 Mon Sep 17 00:00:00 2001 From: Damyon Wiese Date: Fri, 10 Aug 2018 12:41:04 +0800 Subject: [PATCH] MDL-62139 output: Accessible action menus Add label on the "gear" menu and default keyboard and focus controls. --- lang/en/moodle.php | 1 + lib/outputcomponents.php | 7 +- lib/templates/action_menu_link.mustache | 2 +- lib/templates/action_menu_trigger.mustache | 2 +- lib/tests/user_menu_test.php | 5 +- theme/boost/amd/build/aria.min.js | 1 + theme/boost/amd/build/loader.min.js | 2 +- theme/boost/amd/src/aria.js | 188 ++++++++++++++++++ theme/boost/amd/src/loader.js | 8 +- .../boost/templates/core/action_menu.mustache | 2 +- .../core/action_menu_trigger.mustache | 6 +- .../templates/core/custom_menu_item.mustache | 2 +- 12 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 theme/boost/amd/build/aria.min.js create mode 100644 theme/boost/amd/src/aria.js 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}}{{{text}}}{{/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": "" } }} -{{{actiontext}}}{{{menutrigger}}}{{#icon}}{{#pix}}{{key}}, {{component}}, {{title}}{{/pix}}{{/icon}}{{#rawicon}}{{{.}}}{{/rawicon}}{{#menutrigger}}{{/menutrigger}} +{{{actiontext}}}{{{menutrigger}}}{{#icon}}{{#pix}}{{key}}, {{component}}, {{title}}{{/pix}}{{/icon}}{{#rawicon}}{{{.}}}{{/rawicon}}{{#menutrigger}}{{/menutrigger}} 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 @@ } }}