MDL-40975 ActionMenu: Ensure we maintain consistent tab order

When the actionmenu is open, we should focus on it's first element, and
then naturally tab through it. Tabbing from the end should take us to the
first element after the menu button. This may not be the same as the first
element after the menu itself because of the nature of primary and
secondary action links. We also need to shift-tab back in the same manner.
This commit is contained in:
Andrew Nicols 2013-11-05 08:52:00 +08:00
parent 7f4f7081f0
commit 79864ef25f
4 changed files with 214 additions and 25 deletions

View File

@ -11,8 +11,11 @@ var BODY = Y.one(Y.config.doc.body),
MENUSHOWN : 'action-menu-shown'
},
SELECTOR = {
CAN_RECEIVE_FOCUS_SELECTOR: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
MENU : '.moodle-actionmenu[data-enhance=moodle-core-actionmenu]',
MENUCONTENT : '.menu[data-rel=menu-content]',
MENUCONTENTCHILD: 'li a',
MENUCHILD: '.menu li a',
TOGGLE : '.toggle-display'
},
ACTIONMENU,
@ -59,6 +62,15 @@ ACTIONMENU.prototype = {
*/
owner : null,
/**
* The menu button that toggles this open.
*
* @property menulink
* @type Node
* @protected
*/
menulink: null,
/**
* Called during the initialisation process of the object.
* @method initializer
@ -104,16 +116,21 @@ ACTIONMENU.prototype = {
this.dialogue.one(SELECTOR.MENUCONTENT).set('aria-hidden', true);
this.dialogue = null;
}
if (this.owner) {
this.owner.removeClass(CSS.MENUSHOWN);
this.owner = null;
}
for (var i in this.events) {
if (this.events[i].detach) {
this.events[i].detach();
}
}
this.events = [];
if (this.owner) {
this.owner.removeClass(CSS.MENUSHOWN);
this.owner = null;
}
if (this.menulink) {
this.menulink.focus();
this.menulink = null;
}
},
/**
@ -132,13 +149,49 @@ ACTIONMENU.prototype = {
// The menu was visible and the user has clicked to toggle it again.
return;
}
this.showMenu(menu);
this.showMenu(e, menu);
// Close the menu if the user presses escape.
this.events.push(BODY.on('key', this.hideMenu, 'esc', this));
// Close the menu if the user clicks outside the menu.
this.events.push(BODY.on('click', this.hideIfOutside, this));
// Close the menu if the user focuses outside the menu.
this.events.push(BODY.delegate('focus', this.hideIfOutside, '*', this));
// Check tabbing.
this.events.push(menu.delegate('key', this.checkFocus, 'down:9', SELECTOR.MENUCHILD, this));
},
/**
* Check current focus when moving around with the tab key.
* This will ensure that when the etreme menu items are reached, the
* menu is closed and the next DOM element is focused.
*
* @method checkFocus
* @param {EventFacade} e The key event
*/
checkFocus: function(e) {
var nodelist = this.dialogue.all(SELECTOR.MENUCHILD),
firstNode,
lastNode;
if (nodelist) {
firstNode = nodelist.item(0);
lastNode = nodelist.pop();
}
var menulink = this.menulink;
if (e.target === firstNode && e.shiftKey) {
this.hideMenu();
e.preventDefault();
} else if (e.target === lastNode && !e.shiftKey) {
var next;
if (this.hideMenu()) {
next = menulink.next(SELECTOR.CAN_RECEIVE_FOCUS_SELECTOR);
if (next) {
next.focus();
}
}
}
},
/**
@ -156,21 +209,31 @@ ACTIONMENU.prototype = {
/**
* Displays the menu with the given content and alignment.
* @param {EventFacade} e
* @param {Node} menu
* @param Array align
* @returns {M.core.dialogue|dialogue}
*/
showMenu : function(menu) {
showMenu : function(e, menu) {
Y.log('Displaying an action menu', 'debug', ACTIONMENU.NAME);
var ownerselector = menu.getData('owner'),
menucontent = menu.one(SELECTOR.MENUCONTENT);
menucontent = menu.one(SELECTOR.MENUCONTENT),
menuchild;
this.owner = (ownerselector) ? menu.ancestor(ownerselector) : null;
this.dialogue = menu;
menu.addClass('show');
if (this.owner) {
this.owner.addClass(CSS.MENUSHOWN);
this.menulink = this.owner.one(SELECTOR.TOGGLE);
}
this.constrain(menucontent.set('aria-hidden', false));
if (e.type && e.type === 'key') {
menuchild = menucontent.one(SELECTOR.MENUCONTENTCHILD);
if (menuchild) {
menuchild.focus();
}
}
return true;
},

View File

@ -1 +1 @@
YUI.add("moodle-core-actionmenu",function(e,t){var n=e.one(e.config.doc.body),r={MENUSHOWN:"action-menu-shown"},i={MENU:".moodle-actionmenu[data-enhance=moodle-core-actionmenu]",MENUCONTENT:".menu[data-rel=menu-content]",TOGGLE:".toggle-display"},s,o={TL:"tl",TR:"tr",BL:"bl",BR:"br"};s=function(){s.superclass.constructor.apply(this,arguments)},s.prototype={dialogue:null,events:[],owner:null,initializer:function(){e.all(i.MENU).each(this.enhance,this),n.delegate("click",this.toggleMenu,i.MENU+" "+i.TOGGLE,this),n.delegate("key",this.toggleMenu,"enter,space",i.MENU+" "+i.TOGGLE,this)},enhance:function(e){var t=e.one(i.MENUCONTENT),n;if(!t)return!1;n=t.getData("align")||this.get("align").join("-"),e.one(i.TOGGLE).set("aria-haspopup",!0),t.set("aria-hidden",!0),t.hasClass("align-"+n)||t.addClass("align-"+n),t.hasChildNodes()&&e.setAttribute("data-enhanced","1")},hideMenu:function(){this.dialogue&&(this.dialogue.removeClass("show"),this.dialogue.one(i.MENUCONTENT).set("aria-hidden",!0),this.dialogue=null),this.owner&&(this.owner.removeClass(r.MENUSHOWN),this.owner=null);for(var e in this.events)this.events[e].detach&&this.events[e].detach();this.events=[]},toggleMenu:function(e){var t=e.target.ancestor(i.MENU),r=t.hasClass("show");e.halt(!0),this.hideMenu();if(r)return;this.showMenu(t),this.events.push(n.on("key",this.hideMenu,"esc",this)),this.events.push(n.on("click",this.hideIfOutside,this)),this.events.push(n.delegate("focus",this.hideIfOutside,"*",this))},hideIfOutside:function(e){!e.target.test(i.MENU)&&!e.target.ancestor(i.MENU)&&this.hideMenu()},showMenu:function(e){var t=e.getData("owner"),n=e.one(i.MENUCONTENT);return this.owner=t?e.ancestor(t):null,this.dialogue=e,e.addClass("show"),this.owner&&this.owner.addClass(r.MENUSHOWN),this.constrain(n.set("aria-hidden",!1)),!0},constrain:function(e){var t=e.getData("constraint"),n=e.getX(),r=e.getY(),i=e.get("offsetWidth"),s=e.get("offsetHeight"),o=0,u=0,a,f,l="auto",c=null,h=null,p=null,d=null,v=null;t&&(t=e.ancestor(t)),t?(a=t.get("offsetWidth"),f=t.get("offsetHeight"),o=t.getX(),u=t.getY(),l=t.getStyle("overflow")||"auto"):(a=e.get("docWidth"),f=e.get("docHeight")),i>a?(c=i=a,p=n=o):n<o?p=n=o:n+i>=o+a&&(p=o+a-i),s>f&&l.toLowerCase()==="hidden"&&(h=s=f,e.setStyle("overflow","auto"));if(r>=u&&r+s>u+f){d=u+f-s;try{v=e.getStyle("boxShadow").replace(/.*? (\d+)px \d+px$/,"$1"),(new RegExp(/^\d+$/)).test(v)&&d-u>v&&(d-=v)}catch(m){}}p!==null&&e.setX(p),d!==null&&e.setY(d),c!==null&&e.setStyle("width",c.toString()+"px"),h!==null&&e.setStyle("height",h.toString()+"px")}},e.extend(s,e.Base,s.prototype,{NAME:"moodle-core-actionmenu",ATTRS:{align:{value:[o.TR,o.BR]}}}),M.core=M.core||{},M.core.actionmenu=M.core.actionmenu||{},M.core.actionmenu.instance=null,M.core.actionmenu.init=M.core.actionmenu.init||function(e){M.core.actionmenu.instance=M.core.actionmenu.instance||new s(e)},M.core.actionmenu.newDOMNode=function(e){if(M.core.actionmenu.instance===null)return!0;e.all(i.MENU).each(M.core.actionmenu.instance.enhance,M.core.actionmenu.instance)}},"@VERSION@",{requires:["base","event"]});
YUI.add("moodle-core-actionmenu",function(e,t){var n=e.one(e.config.doc.body),r={MENUSHOWN:"action-menu-shown"},i={CAN_RECEIVE_FOCUS_SELECTOR:'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',MENU:".moodle-actionmenu[data-enhance=moodle-core-actionmenu]",MENUCONTENT:".menu[data-rel=menu-content]",MENUCONTENTCHILD:"li a",MENUCHILD:".menu li a",TOGGLE:".toggle-display"},s,o={TL:"tl",TR:"tr",BL:"bl",BR:"br"};s=function(){s.superclass.constructor.apply(this,arguments)},s.prototype={dialogue:null,events:[],owner:null,menulink:null,initializer:function(){e.all(i.MENU).each(this.enhance,this),n.delegate("click",this.toggleMenu,i.MENU+" "+i.TOGGLE,this),n.delegate("key",this.toggleMenu,"enter,space",i.MENU+" "+i.TOGGLE,this)},enhance:function(e){var t=e.one(i.MENUCONTENT),n;if(!t)return!1;n=t.getData("align")||this.get("align").join("-"),e.one(i.TOGGLE).set("aria-haspopup",!0),t.set("aria-hidden",!0),t.hasClass("align-"+n)||t.addClass("align-"+n),t.hasChildNodes()&&e.setAttribute("data-enhanced","1")},hideMenu:function(){this.dialogue&&(this.dialogue.removeClass("show"),this.dialogue.one(i.MENUCONTENT).set("aria-hidden",!0),this.dialogue=null);for(var e in this.events)this.events[e].detach&&this.events[e].detach();this.events=[],this.owner&&(this.owner.removeClass(r.MENUSHOWN),this.owner=null),this.menulink&&(this.menulink.focus(),this.menulink=null)},toggleMenu:function(e){var t=e.target.ancestor(i.MENU),r=t.hasClass("show");e.halt(!0),this.hideMenu();if(r)return;this.showMenu(e,t),this.events.push(n.on("key",this.hideMenu,"esc",this)),this.events.push(n.on("click",this.hideIfOutside,this)),this.events.push(n.delegate("focus",this.hideIfOutside,"*",this)),this.events.push(t.delegate("key",this.checkFocus,"down:9",i.MENUCHILD,this))},checkFocus:function(e){var t=this.dialogue.all(i.MENUCHILD),n,r;t&&(n=t.item(0),r=t.pop());var s=this.menulink;if(e.target===n&&e.shiftKey)this.hideMenu(),e.preventDefault();else if(e.target===r&&!e.shiftKey){var o;this.hideMenu()&&(o=s.next(i.CAN_RECEIVE_FOCUS_SELECTOR),o&&o.focus())}},hideIfOutside:function(e){!e.target.test(i.MENU)&&!e.target.ancestor(i.MENU)&&this.hideMenu()},showMenu:function(e,t){var n=t.getData("owner"),s=t.one(i.MENUCONTENT),o;return this.owner=n?t.ancestor(n):null,this.dialogue=t,t.addClass("show"),this.owner&&(this.owner.addClass(r.MENUSHOWN),this.menulink=this.owner.one(i.TOGGLE)),this.constrain(s.set("aria-hidden",!1)),e.type&&e.type==="key"&&(o=s.one(i.MENUCONTENTCHILD),o&&o.focus()),!0},constrain:function(e){var t=e.getData("constraint"),n=e.getX(),r=e.getY(),i=e.get("offsetWidth"),s=e.get("offsetHeight"),o=0,u=0,a,f,l="auto",c=null,h=null,p=null,d=null,v=null;t&&(t=e.ancestor(t)),t?(a=t.get("offsetWidth"),f=t.get("offsetHeight"),o=t.getX(),u=t.getY(),l=t.getStyle("overflow")||"auto"):(a=e.get("docWidth"),f=e.get("docHeight")),i>a?(c=i=a,p=n=o):n<o?p=n=o:n+i>=o+a&&(p=o+a-i),s>f&&l.toLowerCase()==="hidden"&&(h=s=f,e.setStyle("overflow","auto"));if(r>=u&&r+s>u+f){d=u+f-s;try{v=e.getStyle("boxShadow").replace(/.*? (\d+)px \d+px$/,"$1"),(new RegExp(/^\d+$/)).test(v)&&d-u>v&&(d-=v)}catch(m){}}p!==null&&e.setX(p),d!==null&&e.setY(d),c!==null&&e.setStyle("width",c.toString()+"px"),h!==null&&e.setStyle("height",h.toString()+"px")}},e.extend(s,e.Base,s.prototype,{NAME:"moodle-core-actionmenu",ATTRS:{align:{value:[o.TR,o.BR]}}}),M.core=M.core||{},M.core.actionmenu=M.core.actionmenu||{},M.core.actionmenu.instance=null,M.core.actionmenu.init=M.core.actionmenu.init||function(e){M.core.actionmenu.instance=M.core.actionmenu.instance||new s(e)},M.core.actionmenu.newDOMNode=function(e){if(M.core.actionmenu.instance===null)return!0;e.all(i.MENU).each(M.core.actionmenu.instance.enhance,M.core.actionmenu.instance)}},"@VERSION@",{requires:["base","event"]});

View File

@ -11,8 +11,11 @@ var BODY = Y.one(Y.config.doc.body),
MENUSHOWN : 'action-menu-shown'
},
SELECTOR = {
CAN_RECEIVE_FOCUS_SELECTOR: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
MENU : '.moodle-actionmenu[data-enhance=moodle-core-actionmenu]',
MENUCONTENT : '.menu[data-rel=menu-content]',
MENUCONTENTCHILD: 'li a',
MENUCHILD: '.menu li a',
TOGGLE : '.toggle-display'
},
ACTIONMENU,
@ -59,6 +62,15 @@ ACTIONMENU.prototype = {
*/
owner : null,
/**
* The menu button that toggles this open.
*
* @property menulink
* @type Node
* @protected
*/
menulink: null,
/**
* Called during the initialisation process of the object.
* @method initializer
@ -102,16 +114,21 @@ ACTIONMENU.prototype = {
this.dialogue.one(SELECTOR.MENUCONTENT).set('aria-hidden', true);
this.dialogue = null;
}
if (this.owner) {
this.owner.removeClass(CSS.MENUSHOWN);
this.owner = null;
}
for (var i in this.events) {
if (this.events[i].detach) {
this.events[i].detach();
}
}
this.events = [];
if (this.owner) {
this.owner.removeClass(CSS.MENUSHOWN);
this.owner = null;
}
if (this.menulink) {
this.menulink.focus();
this.menulink = null;
}
},
/**
@ -130,13 +147,49 @@ ACTIONMENU.prototype = {
// The menu was visible and the user has clicked to toggle it again.
return;
}
this.showMenu(menu);
this.showMenu(e, menu);
// Close the menu if the user presses escape.
this.events.push(BODY.on('key', this.hideMenu, 'esc', this));
// Close the menu if the user clicks outside the menu.
this.events.push(BODY.on('click', this.hideIfOutside, this));
// Close the menu if the user focuses outside the menu.
this.events.push(BODY.delegate('focus', this.hideIfOutside, '*', this));
// Check tabbing.
this.events.push(menu.delegate('key', this.checkFocus, 'down:9', SELECTOR.MENUCHILD, this));
},
/**
* Check current focus when moving around with the tab key.
* This will ensure that when the etreme menu items are reached, the
* menu is closed and the next DOM element is focused.
*
* @method checkFocus
* @param {EventFacade} e The key event
*/
checkFocus: function(e) {
var nodelist = this.dialogue.all(SELECTOR.MENUCHILD),
firstNode,
lastNode;
if (nodelist) {
firstNode = nodelist.item(0);
lastNode = nodelist.pop();
}
var menulink = this.menulink;
if (e.target === firstNode && e.shiftKey) {
this.hideMenu();
e.preventDefault();
} else if (e.target === lastNode && !e.shiftKey) {
var next;
if (this.hideMenu()) {
next = menulink.next(SELECTOR.CAN_RECEIVE_FOCUS_SELECTOR);
if (next) {
next.focus();
}
}
}
},
/**
@ -154,20 +207,30 @@ ACTIONMENU.prototype = {
/**
* Displays the menu with the given content and alignment.
* @param {EventFacade} e
* @param {Node} menu
* @param Array align
* @returns {M.core.dialogue|dialogue}
*/
showMenu : function(menu) {
showMenu : function(e, menu) {
var ownerselector = menu.getData('owner'),
menucontent = menu.one(SELECTOR.MENUCONTENT);
menucontent = menu.one(SELECTOR.MENUCONTENT),
menuchild;
this.owner = (ownerselector) ? menu.ancestor(ownerselector) : null;
this.dialogue = menu;
menu.addClass('show');
if (this.owner) {
this.owner.addClass(CSS.MENUSHOWN);
this.menulink = this.owner.one(SELECTOR.TOGGLE);
}
this.constrain(menucontent.set('aria-hidden', false));
if (e.type && e.type === 'key') {
menuchild = menucontent.one(SELECTOR.MENUCONTENTCHILD);
if (menuchild) {
menuchild.focus();
}
}
return true;
},

View File

@ -9,8 +9,11 @@ var BODY = Y.one(Y.config.doc.body),
MENUSHOWN : 'action-menu-shown'
},
SELECTOR = {
CAN_RECEIVE_FOCUS_SELECTOR: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
MENU : '.moodle-actionmenu[data-enhance=moodle-core-actionmenu]',
MENUCONTENT : '.menu[data-rel=menu-content]',
MENUCONTENTCHILD: 'li a',
MENUCHILD: '.menu li a',
TOGGLE : '.toggle-display'
},
ACTIONMENU,
@ -57,6 +60,15 @@ ACTIONMENU.prototype = {
*/
owner : null,
/**
* The menu button that toggles this open.
*
* @property menulink
* @type Node
* @protected
*/
menulink: null,
/**
* Called during the initialisation process of the object.
* @method initializer
@ -102,16 +114,21 @@ ACTIONMENU.prototype = {
this.dialogue.one(SELECTOR.MENUCONTENT).set('aria-hidden', true);
this.dialogue = null;
}
if (this.owner) {
this.owner.removeClass(CSS.MENUSHOWN);
this.owner = null;
}
for (var i in this.events) {
if (this.events[i].detach) {
this.events[i].detach();
}
}
this.events = [];
if (this.owner) {
this.owner.removeClass(CSS.MENUSHOWN);
this.owner = null;
}
if (this.menulink) {
this.menulink.focus();
this.menulink = null;
}
},
/**
@ -130,13 +147,49 @@ ACTIONMENU.prototype = {
// The menu was visible and the user has clicked to toggle it again.
return;
}
this.showMenu(menu);
this.showMenu(e, menu);
// Close the menu if the user presses escape.
this.events.push(BODY.on('key', this.hideMenu, 'esc', this));
// Close the menu if the user clicks outside the menu.
this.events.push(BODY.on('click', this.hideIfOutside, this));
// Close the menu if the user focuses outside the menu.
this.events.push(BODY.delegate('focus', this.hideIfOutside, '*', this));
// Check tabbing.
this.events.push(menu.delegate('key', this.checkFocus, 'down:9', SELECTOR.MENUCHILD, this));
},
/**
* Check current focus when moving around with the tab key.
* This will ensure that when the etreme menu items are reached, the
* menu is closed and the next DOM element is focused.
*
* @method checkFocus
* @param {EventFacade} e The key event
*/
checkFocus: function(e) {
var nodelist = this.dialogue.all(SELECTOR.MENUCHILD),
firstNode,
lastNode;
if (nodelist) {
firstNode = nodelist.item(0);
lastNode = nodelist.pop();
}
var menulink = this.menulink;
if (e.target === firstNode && e.shiftKey) {
this.hideMenu();
e.preventDefault();
} else if (e.target === lastNode && !e.shiftKey) {
var next;
if (this.hideMenu()) {
next = menulink.next(SELECTOR.CAN_RECEIVE_FOCUS_SELECTOR);
if (next) {
next.focus();
}
}
}
},
/**
@ -154,21 +207,31 @@ ACTIONMENU.prototype = {
/**
* Displays the menu with the given content and alignment.
* @param {EventFacade} e
* @param {Node} menu
* @param Array align
* @returns {M.core.dialogue|dialogue}
*/
showMenu : function(menu) {
showMenu : function(e, menu) {
Y.log('Displaying an action menu', 'debug', ACTIONMENU.NAME);
var ownerselector = menu.getData('owner'),
menucontent = menu.one(SELECTOR.MENUCONTENT);
menucontent = menu.one(SELECTOR.MENUCONTENT),
menuchild;
this.owner = (ownerselector) ? menu.ancestor(ownerselector) : null;
this.dialogue = menu;
menu.addClass('show');
if (this.owner) {
this.owner.addClass(CSS.MENUSHOWN);
this.menulink = this.owner.one(SELECTOR.TOGGLE);
}
this.constrain(menucontent.set('aria-hidden', false));
if (e.type && e.type === 'key') {
menuchild = menucontent.one(SELECTOR.MENUCONTENTCHILD);
if (menuchild) {
menuchild.focus();
}
}
return true;
},