(function ($) {
'use strict';
Returns true if the browser supports touch events
@return {boolean}
function supportsTouch() {
return 'ontouchstart' in window || 'onmsgesturechange' in window;
}
The menu class
@param {HTMLElement} item
@param {Array} [options]
@constructor
function AriaMenu(item, options) {
this.settings = $.extend(this.defaults, options);
this.$elem = $(item);
this.init();
}
AriaMenu.prototype = {
Default settings Single quoted keys are required for the google closure compiler: http://closuretools.blogspot.de/2011/01/property-by-any-other-name-part-1.html
defaults: {
'focusClass': 'menuitem-focus',
'visibleMenuClass': 'show-menu',
'closeDelay': 100
},
events: {
Triggered if the users touches a link
@param {jQuery.event=} event
@this {HTMLElement} link
linkTouch: function (event) {
prevent the link execution
event.preventDefault();
var settings = event.data.settings;
Get the links parent element
var $touchedListElement = $(this).parent();
If a sub menu can be found and the user didn't tap this list item item just before
if ($touchedListElement.find('>ul').length && !$touchedListElement.hasClass(settings['focusClass'])) {
open the sub menu
$(this).focus();
prevent phantom clicks
preventPhantomClicks($(this));
} else {
Fix IOS-double click issue http://stackoverflow.com/questions/3038898/ipad-iphone-hover-problem-causes-the-user-to-double-click-a-link
this.click();
}
},
Triggered if the user moves his mouse over a list item
listItemMouseOver: function () {
$(this).focus();
},
Triggered if the user moves his mouse away from a list item
listItemMouseOut: function () {
if ($(this).is(':focus')) {
$(this).blur();
}
},
Triggered if a list item receives focus
@param {jQuery.event=} event
@this {HTMLElement}
listItemFocus: function (event) {
var settings = event.data.settings;
Stop hiding this element (clear timeout from listItemBlur)
debounce(this);
Show the sub menus
$(this)
.addClass(settings['focusClass'])
.find('>ul')
.addClass(settings['visibleMenuClass']);
},
Triggered if the focus is lost
@param {jQuery.event=} event
@this {HTMLElement}
listItemBlur: function (event) {
var settings = event.data.settings;
Wait for a short moment and hide the sub menus
debounce(this, function () {
$(this).removeClass(settings['focusClass'])
.find('>ul')
.removeClass(settings['visibleMenuClass']);
}, settings['closeDelay']);
},
Triggered if a key event bubbles to a root menu list item
@param {jQuery.event=} event
keyDown: function (event) {
var _this = event.data;
Escape pressed:
if (event.which === 27) {
event.preventDefault();
Hide the menu
_this.closeMenu();
}
Arrow keys pressed:
else if (event.which >= 37 && event.which <= 40) {
_this.events.arrowKeyDown.apply(this, arguments);
}
},
Triggered if an arrow key event bubbles to a root menu list item
@param {jQuery.event=} event
arrowKeyDown: function (event) {
var _this = event.data;
Usually the event target is the focused link - so we pick the parent to get the active listElement
var $focusedListElement = $(event.target).closest('li'),
Get the focused menu element
$focusedMenu = $focusedListElement.closest('ul');
Get the sub menu (if one exists)
var $subMenu = $focusedListElement.find('>ul');
Get the parent element of the focused element (if one exists)
var $parentListElement = ($focusedListElement[0] === this) ? $() : $focusedMenu.closest('li'),
Get the parent menu of the focused element (if one exists)
$parentMenu = $parentListElement.parent();
Create a virtual cursor with the position of the focused element
var virtualCursor = new VirtualCursor();
Move the cursor over the current focused element
virtualCursor.moveOver($focusedListElement);
virtualCursor.moveRelative(event.which, $focusedListElement.width(), $focusedListElement.height());
Select the element below the virtual cursor
var $selectedListElement = $(virtualCursor.getElementBelowCursor('li')),
selectedMenu = $selectedListElement.parent()[0] || false;
Check if the virtual cursor selected a sibling menu item or if the virtual cursor selected a sub menu
if ($focusedMenu[0] === selectedMenu || $subMenu[0] === selectedMenu) {
if (_this.selectListElement($selectedListElement)) {
event.preventDefault();
}
}
Check if the virtual cursor selected a parent menu
else if ($parentMenu[0] === selectedMenu) {
Select the parent list element
if (_this.selectListElement($parentListElement)) {
event.preventDefault();
}
}
}
},
Plugin initialization
init: function () {
this.setAriaRoles();
this.$elem
Listen to mouse and keyboard events
.on('focusin', 'li', this, this.events.listItemFocus)
.on('focusout', 'li', this, this.events.listItemBlur)
.on('mouseover', 'a', this, this.events.listItemMouseOver)
.on('mouseout', 'a', this, this.events.listItemMouseOut)
.on('keydown', '>li', this, this.events.keyDown)
.on('touchend', 'a', this, this.events.linkTouch)
Disable the css fallback
.removeClass('css-fallback')
Add aria-menu class
.addClass('aria-menu')
Touch support
.addClass((supportsTouch() ? 'has' : 'no') + '-touch');
},
Helper function
find: function (selector) {
return this.$elem.find(selector);
},
Sets the focus to the first visible anchor child in this list element. The element might be an ul or a li element containing an a anchor.
@param {jQuery} $element
@returns {boolean}
selectListElement: function ($element) {
return $element
Find the a tag starting from the ul or li:
.find('>li>a, >a')
make sure that the anchor is visible
.filter(':visible:first')
set the focus
.focus()
as the filter selects only the first element the length can be either 0 or 1
.length === 1;
},
Add Aria roles and poperties http://http://www.w3.org/TR/wai-aria/
setAriaRoles: function () {
Add ARIA role to menu bar http://www.w3.org/TR/wai-aria/roles#menubar
this.$elem.attr('role', 'menubar');
Add ARIA role to menu items http://www.w3.org/TR/wai-aria/roles#menuitem
this.find('li').attr('role', 'menuitem');
this.find('a+ul')
Adding aria-haspopup for appropriate items http://www.w3.org/TR/wai-aria/statesandproperties#aria-haspopup
.each(function () {
$(this).prev('a').attr('aria-haspopup', 'true');
});
},
Closes the menu by removing the focus
closeMenu: function () {
this.$elem.find(':focus').blur();
}
};
A virtual mouse cursor which helps to support keyboard navigation
@constructor
function VirtualCursor() {
this.left = 0;
this.top = 0;
}
VirtualCursor.prototype = {
Move the virtual cursor over the center of the given target
@param {HTMLElement|jQuery} target
moveOver: function (target) {
var $target = $(target),
$document = $(document);
Set left and top value
$.extend(this, $target.offset());
Move virtual cursor to the middle of the focused element relative to the current scroll position
this.top += 0.5 * $target.height() - $document.scrollTop();
this.left += 0.5 * $target.width() - $document.scrollLeft();
},
Moves the curse relative to the last position
@param direction {number} 37|38|39|40 Direction constant - as in the key code map https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent#Virtualkeycodes DOMVKLEFT 0x25 (37) Left arrow. DOMVKUP 0x26 (38) Up arrow. DOMVKRIGHT 0x27 (39) Right arrow. DOMVKDOWN 0x28 (40) Down arrow.
@param x {number}
@param y {number}
moveRelative: function (direction, x, y) {
{left} or {right} key pressed
if (direction === 37 || direction === 39) {
Move the virtual cursor horizontally event.which - 38 = -1 || +1
this.left += (direction - 38) * x;
}
{up} or {bottom} key pressed
else {
Move the virtual cursor vertically event.which - 39 = -1 || +1
this.top += (direction - 39) * y;
}
},
Return the element at the current courser position
@param {string} [selector]
@returns {jQuery}
getElementBelowCursor: function (selector) {
var $element = $(document.elementFromPoint(this.left, this.top));
if (selector) {
$element = $element.closest(selector);
}
return $element;
}
};
Execute a function once per element after a given delay
@param {HTMLElement|jQuery} element
@param {function()=} [callback]
@param {number=} [delay]
function debounce(element, callback, delay) {
clearTimeout(Number($(element).data('am-delay')));
if (callback && delay) {
$(element).data('am-delay', setTimeout($.proxy(callback, element), delay));
}
}
Prevent Android from triggering buggy phantom clicks http://stackoverflow.com/questions/2987706/touchend-event-doesnt-work-on-android http://stackoverflow.com/questions/17352865/preventdefault-not-stopping-mouseup-event-on-android/19717278 http://jsfiddle.net/FjuHu/6/
@param {jQuery} $element
function preventPhantomClicks($element) {
Click catcher
@param {!jQuery.event=} event
function preventHandler(event) {
event.preventDefault();
}
catch all events for the next 350ms
$element.on('click', preventHandler);
setTimeout(function () {
$element.off('click', preventHandler);
}, 350);
}
jQuery plugin interface Use quoted notation for the closure compiler ADVANCED_OPTIMIZATIONS mode
$['fn']['ariaMenu'] = function (opt) {
return this.each(function () {
var item = $(this), instance = item.data('AriaMenu');
if (!instance) {
create plugin instance and save it in data
item.data('AriaMenu', new AriaMenu(this, opt));
}
});
};
}(jQuery));.
global jQuery: true
jshint sub:true