MDL-62139 output: Accessible action menus

Add label on the "gear" menu and default keyboard and focus controls.
This commit is contained in:
Damyon Wiese 2018-08-10 12:41:04 +08:00
parent c2f1dbcf49
commit 191a1c7c23
12 changed files with 212 additions and 14 deletions

View File

@ -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';

View File

@ -4293,7 +4293,7 @@ class action_menu implements renderable, templatable {
$pixicon = '<b class="caret"></b>';
$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) {

View File

@ -32,5 +32,5 @@
<a href="{{url}}" class="{{classes}}" {{#attributes}}{{name}}={{#quote}}{{value}}{{/quote}} {{/attributes}}{{#showtext}}aria-labelledby="actionmenuaction-{{instance}}"{{/showtext}}>{{#icon}}{{#pix}}{{key}}, {{component}}, {{title}}{{/pix}}{{/icon}}{{#showtext}}<span class="menu-action-text" id="actionmenuaction-{{instance}}">{{{text}}}</span>{{/showtext}}</a>
{{/disabled}}
{{#disabled}}
<span class="currentlink" role="menuitem">{{#icon}}{{#pix}}{{key}}, {{component}}, {{title}}{{/pix}}{{/icon}}{{{text}}}</span>
<span class="currentlink" role="button">{{#icon}}{{#pix}}{{key}}, {{component}}, {{title}}{{/pix}}{{/icon}}{{{text}}}</span>
{{/disabled}}

View File

@ -27,4 +27,4 @@
"triggerextraclasses": ""
}
}}
<a href="#" class="{{triggerextraclasses}} toggle-display {{#menutrigger}}textmenu{{/menutrigger}}" id="action-menu-toggle-{{instance}}" title="{{title}}" role="menuitem">{{{actiontext}}}{{{menutrigger}}}{{#icon}}{{#pix}}{{key}}, {{component}}, {{title}}{{/pix}}{{/icon}}{{#rawicon}}{{{.}}}{{/rawicon}}{{#menutrigger}}<b class="caret"></b>{{/menutrigger}}</a>
<a tabindex="0" class="{{triggerextraclasses}} toggle-display {{#menutrigger}}textmenu{{/menutrigger}}" id="action-menu-toggle-{{instance}}" aria-label="{{title}}" role="menuitem" aria-controls="action-menu-{{instance}}-menu">{{{actiontext}}}{{{menutrigger}}}{{#icon}}{{#pix}}{{key}}, {{component}}, {{title}}{{/pix}}{{/icon}}{{#rawicon}}{{{.}}}{{/rawicon}}{{#menutrigger}}<b class="caret"></b>{{/menutrigger}}</a>

View File

@ -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('/<a [^>]+role="menuitem"[^>]+>/', $output, $results);

1
theme/boost/amd/build/aria.min.js vendored Normal file
View File

@ -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<i.length;h++)if(c=a(i[h]),d=c.text().trim().toLowerCase(),0==d.indexOf(f)){e(c);break}}),a('.dropdown [role="menu"] [role="menuitem"]').keydown(function(b){var c=b.which||b.keyCode,d=!1,f=a(b.target).closest('[role="menu"]'),g=0,h=!1;if(f&&(h=a(f).find('[role="menuitem"]'))){if(40==c){for(g=0;g<h.length-1;g++)if(h[g]==b.target){d=h[g+1];break}d||(d=h[0])}else if(38==c){for(g=1;g<h.length;g++)if(h[g]==b.target){d=h[g-1];break}d||(d=h[h.length-1])}else 36==c?d=h[0]:35==c&&(d=h[h.length-1]);d&&(b.preventDefault(),e(d))}}),a(".dropdown").on("hidden.bs.dropdown",function(b){var c=a(b.target).find('[data-toggle="dropdown"]');c&&e(c)})}}});

View File

@ -1 +1 @@
define(["jquery","./tether","core/event"],function(a,b,c){return window.jQuery=a,window.Tether=b,require(["theme_boost/util","theme_boost/alert","theme_boost/button","theme_boost/carousel","theme_boost/collapse","theme_boost/dropdown","theme_boost/modal","theme_boost/scrollspy","theme_boost/tab","theme_boost/tooltip","theme_boost/popover"],function(){a("body").popover({trigger:"focus",selector:"[data-toggle=popover][data-trigger!=hover]"}),a("html").popover({container:"body",selector:"[data-toggle=popover][data-trigger=hover]",trigger:"hover",delay:{hide:500}}),c.getLegacyEvents().done(function(b){a(document).on(b.FILTER_CONTENT_UPDATED,function(){a("body").popover({selector:'[data-toggle="popover"]',trigger:"focus"})})})}),{}});
define(["jquery","./tether","core/event"],function(a,b,c){return window.jQuery=a,window.Tether=b,require(["theme_boost/aria","theme_boost/util","theme_boost/alert","theme_boost/button","theme_boost/carousel","theme_boost/collapse","theme_boost/dropdown","theme_boost/modal","theme_boost/scrollspy","theme_boost/tab","theme_boost/tooltip","theme_boost/popover"],function(b){a("body").popover({trigger:"focus",selector:"[data-toggle=popover][data-trigger!=hover]"}),a("html").popover({container:"body",selector:"[data-toggle=popover][data-trigger=hover]",trigger:"hover",delay:{hide:500}}),c.getLegacyEvents().done(function(b){a(document).on(b.FILTER_CONTENT_UPDATED,function(){a("body").popover({selector:'[data-toggle="popover"]',trigger:"focus"})})}),b.init()}),{}});

188
theme/boost/amd/src/aria.js Normal file
View File

@ -0,0 +1,188 @@
// 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 <http://www.gnu.org/licenses/>.
/**
* Enhancements to Bootstrap components for accessibility.
*
* @module theme_boost/aria
* @copyright 2018 Damyon Wiese <damyon@moodle.com>
* @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);
}
});
}
};
});

View File

@ -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();
});

View File

@ -52,4 +52,4 @@
</div>
{{/primary}}
</div>
</div>

View File

@ -78,7 +78,7 @@
}
}}
<div class="dropdown">
<a href="#" class="{{triggerextraclasses}} dropdown-toggle" id="dropdown-{{instance}}" title="{{title}}" role="menuitem" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<a href="#" tabindex="0" class="btn {{triggerextraclasses}} dropdown-toggle" id="dropdown-{{instance}}" aria-label="{{title}}" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" aria-controls="action-menu-{{instance}}-menu">
{{{actiontext}}}
{{{menutrigger}}}
{{#icon}}
@ -92,7 +92,7 @@
{{/menutrigger}}
</a>
{{#secondary}}
<div class="dropdown-menu dropdown-menu-right {{classes}}"{{#attributes}} {{name}}="{{value}}"{{/attributes}}>
<div class="dropdown-menu dropdown-menu-right {{classes}}"{{#attributes}} {{name}}="{{value}}"{{/attributes}} id="dropdown-menu-{{instance}}">
{{#items}}
{{#actionmenulink}}
<a href="{{url}}" class="dropdown-item {{classes}}" {{#attributes}}{{name}}={{#quote}}{{value}}{{/quote}} {{/attributes}}{{#showtext}}aria-labelledby="actionmenuaction-{{instance}}"{{/showtext}}>
@ -107,7 +107,7 @@
</a>
{{/actionmenulink}}
{{#actionmenufiller}}
<div class="dropdown-divider"></div>
<div class="dropdown-divider" role="presentation"><span class="filler">&nbsp;</span></div>
{{/actionmenufiller}}
{{^actionmenulink}}
{{^actionmenufiller}}

View File

@ -10,7 +10,7 @@
<a class="dropdown-item" href="{{{url}}}" {{#title}}title="{{{title}}}"{{/title}}>{{{text}}}</a>
{{/divider}}
{{#divider}}
<div class="dropdown-divider"></div>
<div class="dropdown-divider" role="presentation"></div>
{{/divider}}
{{/children}}
</div>