@hilemangroup/bp-frontend
Version:
Common shared frontend utilities for boilerplate projects
409 lines (324 loc) • 12.1 kB
JavaScript
+(function ($) {
var PLUGIN_NAME = 'accessibleMenu';
var defaults = {
hoverClass: 'hover',
focusClass: 'focus',
openClass: 'open',
enableArrowKeys: true,
openOnTab: true,
closeOnHover: true,
allowBothHoverFocus: true,
menuDirection: ['horizontal', 'vertical'],
activate: true,
onActivate: function () {},
onDeactivate: function () {}
};
var Axis = {
HORIZONTAL: 'horizontal',
VERTICAL: 'vertical'
};
var Key = {
TAB: 9,
ENTER: 13,
SHIFT: 16,
ESC: 27,
SPACEBAR: 32,
ARROW_LEFT: 37,
ARROW_UP: 38,
ARROW_RIGHT: 39,
ARROW_DOWN: 40
};
var $body;
function isKeyArrow(key) {
return (
key === Key.ARROW_LEFT ||
key === Key.ARROW_UP ||
key === Key.ARROW_RIGHT ||
key === Key.ARROW_DOWN
);
}
function isKeyArrowForwards(key) {
return (
key === Key.ARROW_RIGHT ||
key === Key.ARROW_DOWN
);
}
function isKeyArrowBackwards(key) {
return (
key === Key.ARROW_LEFT ||
key === Key.ARROW_UP
);
}
function getMenuLevel($anchor, $elem) {
return $anchor.parentsUntil($elem, 'ul').length - 1;
}
function getMenuDirection(level, directions) {
// Get menu direction at current menu level. Default to next
// highest level if direction for current level is not specified.
return directions[level] || directions[directions.length - 1];
}
function isArrowSameDirection(key, direction) {
return (
(
direction === Axis.VERTICAL &&
(key === Key.ARROW_UP || key === Key.ARROW_DOWN)
) || (
direction === Axis.HORIZONTAL &&
(key === Key.ARROW_LEFT || key === Key.ARROW_RIGHT)
)
);
}
function getSubMenu($anchor) {
// Only links inside of lists can trigger a sub-menu
var $item = $anchor.parent('li').closest('li');
return $item.find('ul').not($item.find('ul ul')).filter(function () {
return $(this).find('> li').length;
});
}
function getAnchorNext($anchor) {
var $item = $anchor.closest('li');
var $next = $item.next();
if (!$next.length) {
// Wrap to first item if there is no next item.
$next = $item.siblings('li').first();
}
return $next.find('> a');
}
function getAnchorPrev($anchor) {
var $item = $anchor.closest('li');
var $prev = $item.prev();
if (!$prev.length) {
// Wrap to last item if there is no previous item.
$prev = $item.siblings('li').last();
}
return $prev.find('> a');
}
function findAnchorChildFirst($anchor) {
var $subnav = getSubMenu($anchor);
if ($subnav.length) {
return $subnav.closest('li').find('a:visible').not($anchor).first();
}
return $();
}
function findAnchorChildLast($anchor) {
var $subnav = getSubMenu($anchor);
if ($subnav.length) {
return $subnav.closest('li').find('a').not($anchor).last();
}
return $();
}
function findAnchorParent($anchor) {
var $item = $anchor.closest('li');
return $item.parent().closest('li').find('> a');
}
function setFocus($anchor) {
if (!$anchor.length) {
return $anchor;
}
return $anchor.attr({ 'tabindex': '0' })[0].focus();
}
function Menu(elem, options) {
var self = this;
var settings = $.extend({}, options);
var activated = false;
this.$elem = $(elem);
this.settings = settings;
function removeMenuFocusClass() {
self.$elem.find('li').removeClass(settings.focusClass);
}
function addMenuFocusClass() {
var $focused = $(':focus');
var $menu = $focused.closest(self.$elem);
// First, reset classes on all items.
removeMenuFocusClass();
// Add class to list-item(s) if focused element is a
// descendant of the menu.
if ($menu.length) {
$focused.parentsUntil($menu, 'li')
.addClass(settings.focusClass);
}
}
// Set hover class on list item when a sub-menu has hover.
function mouseEnterHandler() {
var $li = $(this);
if ($li.length) {
$li.addClass(settings.hoverClass);
if (settings.closeOnHover) {
self.$elem.find('li').removeClass(settings.openClass);
}
if (!settings.allowBothHoverFocus) {
removeMenuFocusClass();
}
}
}
function mouseLeaveHandler() {
$(this).removeClass(settings.hoverClass);
if (!settings.allowBothHoverFocus) {
addMenuFocusClass();
}
}
// Set focus class on list item whan a containing link has focus.
function focusHandler(e) {
var $focused = $(e.target);
addMenuFocusClass();
// Remove classes if focus has left the sub-menu.
self.$elem.find('li').filter(function () {
return !$focused.closest(this).length;
})
.removeClass(settings.focusClass)
.removeClass(settings.openClass);
if (!settings.allowBothHoverFocus) {
if (self.$elem.find('li :focus').length) {
// If focus in menu, remove hover class
self.$elem.find('li').removeClass(settings.hoverClass);
} else {
// If focus not in menu, restore hover class
self.$elem.find('li:hover').addClass(settings.hoverClass);
}
}
}
// Listen for arrow key presses and programmatically toggle menu.
function keyDownHandler(e) {
var which = e.which;
var $target = $(e.target);
var hasLink = $target.attr('href');
var level = getMenuLevel($target, elem);
var menuDirection = getMenuDirection(level, settings.menuDirection);
var isArrowDirection = isArrowSameDirection(which, menuDirection);
var $dest;
// If $target is not a descendant of the menu element, exit.
if (!$target.closest(self.$elem).length) {
return;
}
// Navigate to next/previous links within menu.
if (settings.enableArrowKeys && isArrowDirection) {
if (isKeyArrowForwards(which)) {
$dest = getAnchorNext($target);
if ($dest.length) {
setFocus($dest);
}
return false;
} else if (isKeyArrowBackwards(which)) {
$dest = getAnchorPrev($target);
if ($dest.length) {
setFocus($dest);
}
return false;
}
}
// If there is a link, open the sub-menu on SPACEBAR only.
// If there is no link, open sub-menu on SPACEBAR or ENTER.
if (
(
(hasLink && which === Key.SPACEBAR) ||
(!hasLink && (which === Key.ENTER || which === Key.SPACEBAR))
) && getSubMenu($target).length
) {
$target.closest('li').toggleClass(settings.openClass);
return false;
}
// Open sub-menu and navigate to first sub-item if the menu
// item has a sub-menu and the arrow key direction is forwards
// and orthogonal to the current menu level's menu direction
// or if the TAB key has been pressed.
if (
(
(
settings.enableArrowKeys &&
!isArrowDirection &&
isKeyArrowForwards(which)
) ||
(settings.openOnTab && which === Key.TAB && !e.shiftKey)
) && getSubMenu($target).length
) {
$target.closest('li').addClass(settings.openClass);
$dest = findAnchorChildFirst($target);
if ($dest.length) {
setFocus($dest);
return false;
}
}
// Open the previous link, open any nested sub-menus, and
// navigate to last sub-item im the menu when the TAB and
// SHIFT keys are pressed together
if (settings.openOnTab && which === Key.TAB && e.shiftKey) {
$dest = $target.parent('li').closest('li').prev().find('> a');
if ($dest.length) {
while (findAnchorChildLast($dest).length) {
$dest.closest('li').addClass(settings.openClass);
$dest = findAnchorChildLast($dest);
}
setFocus($dest);
return false;
}
}
// Close sub-menu if the arrow key direction is backwards and
// orthogonal to the current menu level's menu direction.
if (
which === Key.ESC || (
settings.enableArrowKeys &&
!isArrowDirection &&
isKeyArrowBackwards(which)
)
) {
$dest = findAnchorParent($target);
if ($dest.length) {
$dest.closest('li').removeClass(settings.openClass);
setFocus($dest);
return false;
}
}
}
function activate() {
if (activated) { return; }
self.$elem.find('li')
.on('mouseenter', mouseEnterHandler)
.on('mouseleave', mouseLeaveHandler);
$body
.on('focus', '*', focusHandler)
.on('keydown', 'a', keyDownHandler);
if (settings.onActivate) {
settings.onActivate.call(self);
}
self.activated = activated = true;
}
function deactivate() {
if (!activated) { return; }
self.$elem.find('li')
.off('mouseenter', mouseEnterHandler)
.off('mouseleave', mouseLeaveHandler);
$body
.off('focus', '*', focusHandler)
.off('keydown', 'a', keyDownHandler);
if (settings.onDeactivate) {
settings.onDeactivate.call(self);
}
self.activated = activated = false;
}
this.activated = activated;
this.activate = activate;
this.deactivate = deactivate;
if (settings.activate) {
activate();
}
}
$(function () {
$body = $(document.body);
});
$.fn[PLUGIN_NAME] = function (options) {
var pluginArgs = arguments;
var settings = $.extend({}, defaults, options);
return this.each(function () {
var instance = $.data(this, PLUGIN_NAME);
if (!instance) {
$.data(this, PLUGIN_NAME, new Menu(this, settings));
} else if (typeof options === 'string' && instance[options]) {
var args = [];
for (var i = 1, ii = pluginArgs.length; i < ii; i++) {
args.push(pluginArgs[i]);
}
instance[options].apply(instance, args);
}
});
};
})(jQuery);