@hilemangroup/bp-frontend
Version:
Common shared frontend utilities for boilerplate projects
344 lines (269 loc) • 9.79 kB
JavaScript
"use strict";
+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 onActivate() {},
onDeactivate: function onDeactivate() {}
};
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);