UNPKG

@hilemangroup/bp-frontend

Version:

Common shared frontend utilities for boilerplate projects

409 lines (324 loc) 12.1 kB
+(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);