UNPKG

@ithaka/bonsai

Version:
411 lines (350 loc) 12.7 kB
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 };