@ithaka/bonsai
Version:
ITHAKA core styling
411 lines (350 loc) • 12.7 kB
JavaScript
import { BonsaiBase } from "./bonsai.base";
import { BonsaiNotification } from "./bonsai.accessible.notifications";
import keyHelper from "./utils/keyboard";
/**
*
* @class BonsaiGlobalNav
* @extends BonsaiBase
*
* @param {jQuery} elements - jQuery object of the container of the navigation
* @param {Object} options - Optional parameters.
*
* @example
* const globalNav = new BonsaiGlobalNav($("[data-top-bar-nav]"));
*
* @returns object that has initialized each element of the navigation
*/
class BonsaiGlobalNav extends BonsaiBase {
constructor(elements, options={}) {
super(elements, options);
this.SRNotify = new BonsaiNotification();
this.hasNotified = false;
this.$activeParent = null;
this.$activeSubNav = null;
this.$activechild = null;
this.$subNavItems = null;
this.openTimer = 0;
this.closeTimer = 0;
this._init();
}
/**
* Initializes the start of the nav component
*
* @private
*/
_init() {
this._generateOptions();
this.elements.each((index, navElement) => {
this._buildParentNavElements($(navElement));
this._events($(navElement));
});
}
/**
* Assembles the final configs to be used by the BonsaiGlobalNav component
*
* @private
*/
_generateOptions() {
this.options = $.extend(this.options, BonsaiGlobalNav.defaults, this.elements.data());
}
/**
* Adds the correct css and aria attributes to the main navigation elements
*
* @param $navElement {jQuery} - main container of the navigation element
* @private
*/
_buildParentNavElements($navElement) {
$navElement.children("li").each((index, childLi) => {
let $childLi = $(childLi),
$subNav = $childLi.children("[data-subnav]");
// to maintain current active page
if($childLi.children("a").hasClass("active")) {
$childLi.children("a").attr("data-keep-active", "");
}
if($subNav.length) {
$childLi.addClass("is-dropdown-submenu-parent opens-right");
$childLi.children("a").attr({
"aria-haspopup": true,
"aria-expanded": false,
"aria-controls": $subNav.attr("id")
});
BonsaiGlobalNav._generateSubnavElement($subNav);
} else {
$childLi.addClass("menu-item");
}
});
}
/**
* Adds the needed css classes for a submenu
*
* @param $subNav {jQuery} - direct child li element of the navigation
* @private
* @static
*/
static _generateSubnavElement($subNav) {
$subNav.addClass("submenu is-dropdown-submenu vertical");
if(!$subNav.is("[data-mega-menu]")) {
$subNav.find("li").each( (index, subnavItem) => {
$(subnavItem).addClass("is-submenu-item");
});
}
}
/**
* Maps user interactions with both the mouse and keyboard to menu actions
*
* @param $navElement {jQuery} - the main container of whole navigation
* @private
*/
_events($navElement) {
$navElement.find(" > li > a ").on({
"click": (event) => {
if(this._hasSubmenu($(event.target))) {
event.preventDefault();
}
},
"focus": () => {
if(!this.hasNotified) {
this.SRNotify.sendMessage(`Press the spacebar to open menu options. Use the up and down
arrows to navigate menu options. Press escape to close the menu.`);
this.hasNotified = true;
}
},
"blur" : (event) => {
if($(event.target).closest("li").is(":last-child")) {
this.hasNotified = false;
}
},
"keydown": (event) => {
switch(true) {
case keyHelper.wasSpaceBar(event.keyCode) || keyHelper.wasEnter(event.keyCode):
event.preventDefault();
this._toggleMenu($(event.target));
break;
case keyHelper.wasEscape(event.keyCode):
event.preventDefault();
this.closeMenu();
break;
case keyHelper.wasDownArrow(event.keyCode):
if(this.$activeParent) {
event.preventDefault();
this._nextChildOption();
}
break;
case keyHelper.wasUpArrow(event.keyCode):
if(this.$activeParent) {
event.preventDefault();
this._previousChildOption();
}
break;
}
},
"mouseenter": (event) => {
this.openMenu($(event.target));
},
"mouseleave": (event) => {
this.closeMenu($(event.target));
}
});
$navElement.find("[data-subnav]").on({
"mouseenter focus": () => {
clearTimeout(this.closeTimer);
},
"mouseleave blur": () => {
this.closeMenu();
},
"keydown": (event) => {
if(keyHelper.wasUpArrow(event.keyCode) || keyHelper.wasShiftTab(event)) {
event.preventDefault();
this._previousChildOption();
}
else if(keyHelper.wasDownArrow(event.keyCode) || (keyHelper.wasTab(event.keyCode) && !keyHelper.wasShiftTab(event))) {
event.preventDefault();
this._nextChildOption();
}
else if(keyHelper.wasEscape(event.keyCode)) {
event.preventDefault();
this.close();
}
}
});
}
/**
* Selects the next item in the submenu based on the current item selected. If there is not
* one currently selected, it selects the first item. If the currently selected item is the
* last item in the submenu, it selects the first
*
* @private
*/
_nextChildOption() {
if(!this.$activechild) {
this.$activechild = this.$subNavItems.first();
}
else {
let currentItemPosition = this._getCurrentItemIndex(),
newPosition;
if(currentItemPosition === this.$subNavItems.length - 1) {
currentItemPosition = -1;
}
newPosition = currentItemPosition + 1;
if(newPosition === this.$subNavItems.length - 1) {
this.SRNotify.sendMessage("Last menu option reached");
}
this.$activechild.removeClass("current-item");
this.$activechild = $(this.$subNavItems.get(newPosition));
}
this.$activechild.addClass("current-item");
this.$activechild.focus();
}
/**
* Selects the previous item in the submenu based on the current item selected. If there is not
* one currently selected, it selects the last item. If the current item selected is the first
* in the submenu, it selects the last
*
* @private
*/
_previousChildOption() {
if(!this.$activechild) {
this.$activechild = this.$activeSubNav.find("li > a").last();
}
else {
let currentItemPosition = this._getCurrentItemIndex(),
newPosition;
if(currentItemPosition < 0) {
currentItemPosition = this.$subNavItems.length - 1;
}
newPosition = currentItemPosition - 1;
if(newPosition === 0) {
this.SRNotify.sendMessage("First menu option reached");
}
this.$activechild.removeClass("current-item");
this.$activechild = $(this.$subNavItems.get(newPosition));
}
this.$activechild.addClass("current-item");
this.$activechild.focus();
}
/**
* Gets where in the submenu the current item that is selected
*
* @returns {number}
* @private
*/
_getCurrentItemIndex() {
let returnIndex = -1;
this.$subNavItems.each((index, subNavItem) => {
if($(subNavItem).hasClass("current-item")) {
returnIndex = index;
// to break the $.each() loop
return false;
}
});
return returnIndex;
}
/**
* Checks if the menu item controls a submenu
*
* @param $menuItem {jQuery} - the main menu anchor being interacted with
* @returns {boolean}
* @private
*/
_hasSubmenu($menuItem) {
return $menuItem.next().is("[data-subnav]");
}
/**
* Toggles the menu open or closed based on the current state. Recommend using for keyboard interactions
*
* @param $menuItem {jQuery} - the main menu anchor being interacted with
* @private
*/
_toggleMenu($menuItem) {
if(typeof $menuItem.next().is("[data-subnav]")) {
if($menuItem.hasClass("active") && !$menuItem.is("[data-keep-active]")) {
this.close();
} else {
this.open($menuItem);
}
}
}
/**
* Opens the menu using the BonsaiGlobalNav.options.hoverDelay timeout. Recommend using for mouse interactions
*
* @param $menuItem {jQuery} - the main menu anchor being interacted with
* @public
*/
openMenu($menuItem) {
clearTimeout(this.openTimer);
this.openTimer = setTimeout(() => {
this.open($menuItem);
}, this.options.hoverDelay);
}
/**
* Closes the menu using the BonsaiGlobalNav.options.closeDelay timeout. Recommend using for mouse interactions
*
* @public
*/
closeMenu() {
clearTimeout(this.closeTimer);
clearTimeout(this.openTimer);
this.closeTimer = setTimeout(() => {
this.close();
}, this.options.closeDelay);
}
/**
* Opens the current menu item being interacted with. Recommend using directly with keyboard interactions
*
* @param $menuItem {jQuery} - the main menu anchor being interacted with
* @public
*/
open($menuItem) {
$menuItem.addClass("active");
// To avoid the appearance of two menu items having focus at the same time
if($(":focus").text() !== $menuItem.text()) {
$(":focus").blur();
}
this.$activeParent = $menuItem;
if(this._hasSubmenu(this.$activeParent)) {
this.$activeParent.attr("aria-expanded", "true");
if(this.$activeParent.next().is("[data-mega-menu]")){
this.$activeParent.next().addClass("is-open");
} else {
this.$activeParent.next().addClass("js-dropdown-active");
}
this.$activeSubNav = this.$activeParent.closest(".is-dropdown-submenu-parent").find("[data-subnav]");
this.$subNavItems = this.$activeSubNav.find("li > a");
}
}
/**
* Close method closes current $activeParent that's opened. Recommend using directly with keyboard interactions
*
* @public
*/
close() {
if(this.$activeParent) {
if(!this.$activeParent.is("[data-keep-active]")) {
this.$activeParent.removeClass("active");
}
if(this._hasSubmenu(this.$activeParent)) {
this.$activeParent.focus();
this.$activeParent.attr("aria-expanded", "false");
if(this.$activeParent.next().is("[data-mega-menu]")){
this.$activeParent.next().removeClass("is-open");
} else {
this.$activeParent.next().removeClass("js-dropdown-active");
}
}
this.$activeParent = null;
this.$activeSubNav = null;
this.$activechild = null;
this.$subNavItems = null;
}
}
}
/**
* Object to hold the component settings' default values
*
* @type {{hoverDelay: number, closeDelay: number}}
*/
BonsaiGlobalNav.defaults = {
hoverDelay: 500,
closeDelay: 100
};
export { BonsaiGlobalNav };