/* * HS Mega Menu - jQuery Plugin * @version: 1.0.0 (Sun, 26 Feb 2017) * @requires: jQuery v1.6 or later * @author: HtmlStream * @event-namespace: .HSMegaMenu * @browser-support: IE9+ * @license: * * Copyright 2017 HtmlStream * */ ;(function($){ 'use strict'; /** * Creates a mega menu. * * @constructor * @param {HTMLElement|jQuery} element - The element to create the mega menu for. * @param {Object} options - The options */ function MegaMenu(element, options) { var self = this; /** * Current element. * * @public */ this.$element = $(element); /** * Current options set by the caller including defaults. * * @public */ this.options = $.extend(true, {}, MegaMenu.defaults, options); /** * Collection of menu elements. * * @private */ this._items = $(); Object.defineProperties( this, { /** * Contains composed selector of menu items. * * @public */ itemsSelector: { get: function() { return self.options.classMap.hasSubMenu + ',' + self.options.classMap.hasMegaMenu; } }, /** * Contains chain of active items. * * @private */ _tempChain: { value: null, writable: true }, /** * Contains current behavior state. * * @public */ state: { value: null, writable: true } }); this.initialize(); }; /** * Default options of the mega menu. * * @public */ MegaMenu.defaults = { event: 'hover', direction: 'horizontal', breakpoint: 991, animationIn: false, animationOut: false, rtl: false, hideTimeOut: 300, // For 'vertical' direction only sideBarRatio: 1/4, pageContainer: $('body'), classMap: { initialized: '.hs-menu-initialized', mobileState: '.hs-mobile-state', subMenu: '.hs-sub-menu', hasSubMenu: '.hs-has-sub-menu', hasSubMenuActive: '.hs-sub-menu-opened', megaMenu: '.hs-mega-menu', hasMegaMenu: '.hs-has-mega-menu', hasMegaMenuActive: '.hs-mega-menu-opened' }, mobileSpeed: 400, mobileEasing: 'linear', beforeOpen: function(){}, beforeClose: function(){}, afterOpen: function(){}, afterClose: function(){} }; /** * Initialization of the plugin. * * @protected */ MegaMenu.prototype.initialize = function() { var self = this, $w = $(window); if( this.options.rtl ) this.$element.addClass('hs-rtl'); this.$element .addClass(this.options.classMap.initialized.slice(1)) .addClass('hs-menu-' + this.options.direction); // Independent events $w.on('resize.HSMegaMenu', function(e){ if( self.resizeTimeOutId ) clearTimeout( self.resizeTimeOutId ); self.resizeTimeOutId = setTimeout( function(){ if($w.width() <= self.options.breakpoint && self.state == 'desktop') self.initMobileBehavior(); else if( $w.width() > self.options.breakpoint && self.state == 'mobile' ) self.initDesktopBehavior(); self.refresh(); }, 50 ); }); $(document) .on('click.HSMegaMenu', function(e){ var $parents = $(e.target).parents(self.itemsSelector); self.closeAll( $parents.add($(e.target)) ); }) .on('keyup.HSMegaMenu', function(e){ if( e.keyCode && e.keyCode == 27 ) self.closeAll(); }); if($w.width() <= this.options.breakpoint) this.initMobileBehavior(); else if( $w.width() > this.options.breakpoint) this.initDesktopBehavior(); this.smartPositions(); return this; }; MegaMenu.prototype.smartPositions = function() { var self = this, $submenus = this.$element.find( this.options.classMap.subMenu ); $submenus.each(function(i, el) { MenuItem.smartPosition( $(el), self.options ); }); }; /** * Binding events to menu elements. * * @protected */ MegaMenu.prototype.bindEvents = function() { var self = this, selector; // Hover case // if(this.options.event === 'hover' && !_isTouch()) { if(this.options.event === 'hover') { this.$element .on( 'mouseenter.HSMegaMenu', this.options.classMap.hasMegaMenu + ':not([data-event="click"]),' + this.options.classMap.hasSubMenu + ':not([data-event="click"])', function(e) { var $this = $(this), $chain = $this.parents( self.itemsSelector); // Lazy initialization if( !$this.data('HSMenuItem') ) { self.initMenuItem( $this, self.getType( $this ) ); } $chain = $chain.add($this); self.closeAll( $chain ); $chain.each(function(i, el){ var HSMenuItem = $(el).data('HSMenuItem'); if(HSMenuItem.hideTimeOutId) clearTimeout(HSMenuItem.hideTimeOutId); HSMenuItem.show(); }); self._items = self._items.not( $chain ); self._tempChain = $chain; e.preventDefault(); e.stopPropagation(); } ) .on( 'mouseleave.HSMegaMenu', this.options.classMap.hasMegaMenu + ':not([data-event="click"]),' + this.options.classMap.hasSubMenu + ':not([data-event="click"])', function(e) { var $this = $(this), HSMenuItem = $this.data('HSMenuItem'), $chain = $(e.relatedTarget).parents( self.itemsSelector ); HSMenuItem.hideTimeOutId = setTimeout( function(){ self.closeAll( $chain ); }, self.options.hideTimeOut ); self._items = self._items.add( self._tempChain ); self._tempChain = null; e.preventDefault(); e.stopPropagation(); } ) .on( 'click.HSMegaMenu', this.options.classMap.hasMegaMenu + '[data-event="click"] > a, ' + this.options.classMap.hasSubMenu + '[data-event="click"] > a', function(e) { var $this = $(this).parent('[data-event="click"]'), HSMenuItem; // Lazy initialization if(!$this.data('HSMenuItem')) { self.initMenuItem( $this, self.getType( $this ) ); } self.closeAll( $this.add( $this.parents(self.itemsSelector) ) ); HSMenuItem = $this .data('HSMenuItem'); if(HSMenuItem.isOpened) { HSMenuItem.hide(); } else{ HSMenuItem.show(); } e.preventDefault(); e.stopPropagation(); } ); } // Click case else { this.$element .on( 'click.HSMegaMenu', (_isTouch() ? this.options.classMap.hasMegaMenu + ' > a, ' + this.options.classMap.hasSubMenu + ' > a' : this.options.classMap.hasMegaMenu + ':not([data-event="hover"]) > a,' + this.options.classMap.hasSubMenu + ':not([data-event="hover"]) > a'), function(e) { var $this = $(this).parent(), HSMenuItem, $parents = $this.parents(self.itemsSelector); // Lazy initialization if(!$this.data('HSMenuItem')) { self.initMenuItem( $this, self.getType( $this ) ); } self.closeAll( $this.add( $this.parents( self.itemsSelector ) ) ); HSMenuItem = $this .addClass('hs-event-prevented') .data('HSMenuItem'); if(HSMenuItem.isOpened) { HSMenuItem.hide(); } else{ HSMenuItem.show(); } e.preventDefault(); e.stopPropagation(); } ); if(!_isTouch()) { this.$element .on( 'mouseenter.HSMegaMenu', this.options.classMap.hasMegaMenu + '[data-event="hover"],' + this.options.classMap.hasSubMenu + '[data-event="hover"]', function(e) { var $this = $(this), $parents = $this.parents( self.itemsSelector); // Lazy initialization if( !$this.data('HSMenuItem') ) { self.initMenuItem( $this, self.getType( $this ) ); } self.closeAll( $this.add($parents) ); $parents.add($this).each(function(i, el){ var HSMenuItem = $(el).data('HSMenuItem'); if(HSMenuItem.hideTimeOutId) clearTimeout(HSMenuItem.hideTimeOutId); HSMenuItem.show(); }); e.preventDefault(); e.stopPropagation(); } ) .on( 'mouseleave.HSMegaMenu', this.options.classMap.hasMegaMenu + '[data-event="hover"],' + this.options.classMap.hasSubMenu + '[data-event="hover"]', function(e) { var $this = $(this), HSMenuItem = $this.data('HSMenuItem'); HSMenuItem.hideTimeOutId = setTimeout( function(){ self.closeAll( $(e.relatedTarget).parents(self.itemsSelector) ); }, self.options.hideTimeOut ); e.preventDefault(); e.stopPropagation(); } ) } } }; /** * Initialization of certain menu item. * * @protected */ MegaMenu.prototype.initMenuItem = function(element, type) { var self = this, Item = new MenuItem( element, element.children( self.options.classMap[type === 'mega-menu' ? 'megaMenu' : 'subMenu'] ), $.extend(true, {type: type}, self.options, element.data()), self.$element ); element.data( 'HSMenuItem', Item ); this._items = this._items.add( element ); }; /** * Destroys of desktop behavior, then makes initialization of mobile behavior. * * @protected */ MegaMenu.prototype.initMobileBehavior = function() { var self = this; this.state = 'mobile'; this.$element .off('.HSMegaMenu') .addClass( this.options.classMap.mobileState.slice(1) ) .on('click.HSMegaMenu', self.options.classMap.hasSubMenu + ' > a, ' + self.options.classMap.hasMegaMenu + ' > a', function(e){ var $this = $(this).parent(), MenuItemInstance; // Lazy initialization if( !$this.data('HSMenuItem') ) { self.initMenuItem( $this, self.getType( $this ) ); } self.closeAll( $this.parents( self.itemsSelector ).add($this) ); MenuItemInstance = $this .data('HSMenuItem'); console.log(MenuItemInstance.isOpened); if(MenuItemInstance.isOpened) { MenuItemInstance.mobileHide(); } else { MenuItemInstance.mobileShow(); } e.preventDefault(); e.stopPropagation(); }) .find( this.itemsSelector ) .not( this.options.classMap.hasSubMenuActive + ',' + this.options.classMap.hasMegaMenuActive ) .children( this.options.classMap.subMenu + ',' + this.options.classMap.megaMenu ) .hide(); }; /** * Destroys of mobile behavior, then makes initialization of desktop behavior. * * @protected */ MegaMenu.prototype.initDesktopBehavior = function() { this.state = 'desktop'; this.$element .removeClass(this.options.classMap.mobileState.slice(1)) .off('.HSMegaMenu') .find( this.itemsSelector ) .not( this.options.classMap.hasSubMenuActive + ',' + this.options.classMap.hasMegaMenuActive ) .children( this.options.classMap.subMenu + ',' + this.options.classMap.megaMenu ) .hide(); this.bindEvents(); }; /** * Hides all of opened submenus/megamenus. * * @param {jQuery} except - collection of elements, which shouldn't be closed. * @return {jQuery} * @public */ MegaMenu.prototype.closeAll = function(except) { var self = this; return this._items.not(except && except.length ? except : $()).each(function(i, el){ $(el) .removeClass('hs-event-prevented') .data('HSMenuItem')[self.state == 'mobile' ? 'mobileHide' : 'hide'](); }); }; /** * Returns type of sub menu based on specified menu item. * * @param {jQuery} item * @return {String|null} * @public */ MegaMenu.prototype.getType = function( item ) { if(!item || !item.length) return null; return item.hasClass(this.options.classMap.hasSubMenu.slice(1)) ? 'sub-menu' : (item.hasClass(this.options.classMap.hasMegaMenu.slice(1)) ? 'mega-menu' : null); }; /** * Returns current menu state. * * @return {String} * @public */ MegaMenu.prototype.getState = function() { return this.state; }; /** * Updates bounds of all menu items. * * @return {jQuery} * @public */ MegaMenu.prototype.refresh = function() { return this._items.add( this._tempChain ).each(function(i, el){ $(el).data('HSMenuItem')._updateMenuBounds(); }); }; /** * Creates a mega-menu element. * * @param {jQuery} element * @param {jQuery} menu * @param {Object} options * @param {jQuery} container * @constructor */ function MenuItem(element, menu, options, container) { var self = this; /** * Current menu item element. * * @public */ this.$element = element; /** * Current mega menu element. * * @public */ this.menu = menu; /** * Item options. * * @public */ this.options = options; /** * MegaMenu container. * * @public */ this.$container = container; Object.defineProperties(this, { /** * Contains css class of menu item element. * * @public */ itemClass: { get: function() { return self.options.type === 'mega-menu' ? self.options.classMap.hasMegaMenu : self.options.classMap.hasSubMenu; } }, /** * Contains css active-class of menu item element. * * @public */ activeItemClass: { get: function() { return self.options.type === 'mega-menu' ? self.options.classMap.hasMegaMenuActive : self.options.classMap.hasSubMenuActive; } }, /** * Contains css class of menu element. * * @public */ menuClass: { get: function() { return self.options.type === 'mega-menu' ? self.options.classMap.megaMenu : self.options.classMap.subMenu; } }, isOpened: { get: function() { return this.$element.hasClass(this.activeItemClass.slice(1)); } } }); this.menu.addClass('animated').on('click.HSMegaMenu', function(e){ self._updateMenuBounds(); }); if( this.$element.data('max-width') ) this.menu.css('max-width', this.$element.data('max-width') ); if( this.$element.data('position') ) this.menu.addClass( 'hs-position-' + this.$element.data('position') ); if( this.options.animationOut ) { this.menu.on('webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend', function(e) { if(self.menu.hasClass(self.options.animationOut)) { self.$element.removeClass(self.activeItemClass.slice(1)); self.options.afterClose.call(self, self.$element, self.menu); } if(self.menu.hasClass(self.options.animationIn)) { self.options.afterOpen.call(self, self.$element, self.menu); } e.stopPropagation(); e.preventDefault(); }); } } /** * Shows the mega-menu item. * * @public * @return {MenuItem} */ MenuItem.prototype.show = function() { if( !this.menu.length ) return this; this.$element.addClass(this.activeItemClass.slice(1)); if(this.options.direction == 'horizontal') this.smartPosition( this.menu, this.options ); this._updateMenuBounds(); if(this.options.animationOut) { this.menu.removeClass(this.options.animationOut); } else { this.options.afterOpen.call(this, this.$element, this.menu); } if(this.options.animationIn) this.menu.addClass(this.options.animationIn); return this; } /** * Hides the mega-menu item. * * @public * @return {MenuItem} */ MenuItem.prototype.hide = function() { var self = this; if( !this.menu.length ) return this; if(!this.options.animationOut) { this.$element.removeClass(this.activeItemClass.slice(1)); } if(this.options.animationIn) this.menu.removeClass(this.options.animationIn); if(this.options.animationOut) { this.menu .addClass(this.options.animationOut); } else{ this.options.afterClose.call(this, this.$element, this.menu); } return this; } /** * Shows the mega-menu item. * * @public * @return {MenuItem} */ MenuItem.prototype.mobileShow = function() { var self = this; if( !this.menu.length ) return this; this.menu .removeClass( this.options.animationIn ) .removeClass( this.options.animationOut ) .stop() .slideDown({ duration: self.options.mobileSpeed, easing: self.options.mobileEasing, complete: function() { self.options.afterOpen.call(self, self.$element, self.menu); } }); this.$element.addClass(this.activeItemClass.slice(1)); return this; }; /** * Hides the mega-menu item. * * @public * @return {MenuItem} */ MenuItem.prototype.mobileHide = function() { var self = this; if( !this.menu.length ) return this; this.menu.stop().slideUp({ duration: self.options.mobileSpeed, easing: self.options.mobileEasing, complete: function() { self.options.afterClose.call(self, self.$element, self.menu); } }); this.$element.removeClass(this.activeItemClass.slice(1)); return this; }; /** * Check, if element is in viewport. * * @param {jQuery} element * @param {Object} options * @public */ MenuItem.prototype.smartPosition = function( element, options ) { MenuItem.smartPosition(element, options); }; /** * Check, if element is in viewport. * * @param {jQuery} element * @param {Object} options * @static * @public */ MenuItem.smartPosition = function( element, options ) { if(!element && !element.length) return; var $w = $(window); element.removeClass('hs-reversed'); if(!options.rtl) { if( element.offset().left + element.outerWidth() > $w.width() ) { element.addClass('hs-reversed'); } } else { if(element.offset().left < 0) { element.addClass('hs-reversed'); } } }; /** * Updates bounds of current opened menu. * * @private */ MenuItem.prototype._updateMenuBounds = function() { var width = 'auto'; if( this.options.direction == 'vertical' && this.options.type == 'mega-menu' ) { if( this.$container && this.$container.data('HSMegaMenu').getState() == 'desktop' ) { if( !this.options.pageContainer.length ) this.options.pageContainer = $('body'); width = this.options.pageContainer.outerWidth() * (1 - this.options.sideBarRatio); } else { width = 'auto'; } this.menu.css({ 'width': width, 'height': 'auto' }); if( this.menu.outerHeight() > this.$container.outerHeight() ) { return; } this.menu.css('height', '100%'); } }; /** * The jQuery plugin for the MegaMenu. * * @public */ $.fn.HSMegaMenu = function(options) { return this.each(function(i, el) { var $this = $(this); if(!$this.data('HSMegaMenu')) { $this.data('HSMegaMenu', new MegaMenu($this, options)); } }); }; /** * Helper function for detect touch events in the environment. * * @return {Boolean} * @private */ function _isTouch() { return ('ontouchstart' in window); } })(jQuery);