/*! * SmartMenus jQuery Plugin - v1.0.0 - January 27, 2016 * http://www.smartmenus.org/ * * Copyright Vasil Dinkov, Vadikom Web Ltd. * http://vadikom.com * * Licensed MIT */ (function(factory) { if (typeof define === 'function' && define.amd) { // AMD define(['jquery'], factory); } else if (typeof module === 'object' && typeof module.exports === 'object') { // CommonJS module.exports = factory(require('jquery')); } else { // Global jQuery factory(jQuery); } } (function($) { var menuTrees = [], IE = !!window.createPopup, // detect it for the iframe shim mouse = false, // optimize for touch by default - we will detect for mouse input touchEvents = 'ontouchstart' in window, // we use this just to choose between toucn and pointer events, not for touch screen detection mouseDetectionEnabled = false, requestAnimationFrame = window.requestAnimationFrame || function(callback) { return setTimeout(callback, 1000 / 60); }, cancelAnimationFrame = window.cancelAnimationFrame || function(id) { clearTimeout(id); }; // Handle detection for mouse input (i.e. desktop browsers, tablets with a mouse, etc.) function initMouseDetection(disable) { var eNS = '.smartmenus_mouse'; if (!mouseDetectionEnabled && !disable) { // if we get two consecutive mousemoves within 2 pixels from each other and within 300ms, we assume a real mouse/cursor is present // in practice, this seems like impossible to trick unintentianally with a real mouse and a pretty safe detection on touch devices (even with older browsers that do not support touch events) var firstTime = true, lastMove = null; $(document).bind(getEventsNS([ ['mousemove', function(e) { var thisMove = { x: e.pageX, y: e.pageY, timeStamp: new Date().getTime() }; if (lastMove) { var deltaX = Math.abs(lastMove.x - thisMove.x), deltaY = Math.abs(lastMove.y - thisMove.y); if ((deltaX > 0 || deltaY > 0) && deltaX <= 2 && deltaY <= 2 && thisMove.timeStamp - lastMove.timeStamp <= 300) { mouse = true; // if this is the first check after page load, check if we are not over some item by chance and call the mouseenter handler if yes if (firstTime) { var $a = $(e.target).closest('a'); if ($a.is('a')) { $.each(menuTrees, function() { if ($.contains(this.$root[0], $a[0])) { this.itemEnter({ currentTarget: $a[0] }); return false; } }); } firstTime = false; } } } lastMove = thisMove; }], [touchEvents ? 'touchstart' : 'pointerover pointermove pointerout MSPointerOver MSPointerMove MSPointerOut', function(e) { if (isTouchEvent(e.originalEvent)) { mouse = false; } }] ], eNS)); mouseDetectionEnabled = true; } else if (mouseDetectionEnabled && disable) { $(document).unbind(eNS); mouseDetectionEnabled = false; } } function isTouchEvent(e) { return !/^(4|mouse)$/.test(e.pointerType); } // returns a jQuery bind() ready object function getEventsNS(defArr, eNS) { if (!eNS) { eNS = ''; } var obj = {}; $.each(defArr, function(index, value) { obj[value[0].split(' ').join(eNS + ' ') + eNS] = value[1]; }); return obj; } $.SmartMenus = function(elm, options) { this.$root = $(elm); this.opts = options; this.rootId = ''; // internal this.accessIdPrefix = ''; this.$subArrow = null; this.activatedItems = []; // stores last activated A's for each level this.visibleSubMenus = []; // stores visible sub menus UL's (might be in no particular order) this.showTimeout = 0; this.hideTimeout = 0; this.scrollTimeout = 0; this.clickActivated = false; this.focusActivated = false; this.zIndexInc = 0; this.idInc = 0; this.$firstLink = null; // we'll use these for some tests this.$firstSub = null; // at runtime so we'll cache them this.disabled = false; this.$disableOverlay = null; this.$touchScrollingSub = null; this.cssTransforms3d = 'perspective' in elm.style || 'webkitPerspective' in elm.style; this.wasCollapsible = false; this.init(); }; $.extend($.SmartMenus, { hideAll: function() { $.each(menuTrees, function() { this.menuHideAll(); }); }, destroy: function() { while (menuTrees.length) { menuTrees[0].destroy(); } initMouseDetection(true); }, prototype: { init: function(refresh) { var self = this; if (!refresh) { menuTrees.push(this); this.rootId = (new Date().getTime() + Math.random() + '').replace(/\D/g, ''); this.accessIdPrefix = 'sm-' + this.rootId + '-'; if (this.$root.hasClass('sm-rtl')) { this.opts.rightToLeftSubMenus = true; } // init root (main menu) var eNS = '.smartmenus'; this.$root .data('smartmenus', this) .attr('data-smartmenus-id', this.rootId) .dataSM('level', 1) .bind(getEventsNS([ ['mouseover focusin', $.proxy(this.rootOver, this)], ['mouseout focusout', $.proxy(this.rootOut, this)], ['keydown', $.proxy(this.rootKeyDown, this)] ], eNS)) .delegate('a', getEventsNS([ ['mouseenter', $.proxy(this.itemEnter, this)], ['mouseleave', $.proxy(this.itemLeave, this)], ['mousedown', $.proxy(this.itemDown, this)], ['focus', $.proxy(this.itemFocus, this)], ['blur', $.proxy(this.itemBlur, this)], ['click', $.proxy(this.itemClick, this)] ], eNS)); // hide menus on tap or click outside the root UL eNS += this.rootId; if (this.opts.hideOnClick) { $(document).bind(getEventsNS([ ['touchstart', $.proxy(this.docTouchStart, this)], ['touchmove', $.proxy(this.docTouchMove, this)], ['touchend', $.proxy(this.docTouchEnd, this)], // for Opera Mobile < 11.5, webOS browser, etc. we'll check click too ['click', $.proxy(this.docClick, this)] ], eNS)); } // hide sub menus on resize $(window).bind(getEventsNS([['resize orientationchange', $.proxy(this.winResize, this)]], eNS)); if (this.opts.subIndicators) { this.$subArrow = $('').addClass('sub-arrow'); if (this.opts.subIndicatorsText) { this.$subArrow.html(this.opts.subIndicatorsText); } } // make sure mouse detection is enabled initMouseDetection(); } // init sub menus this.$firstSub = this.$root.find('ul').each(function() { self.menuInit($(this)); }).eq(0); this.$firstLink = this.$root.find('a').eq(0); // find current item if (this.opts.markCurrentItem) { var reDefaultDoc = /(index|default)\.[^#\?\/]*/i, reHash = /#.*/, locHref = window.location.href.replace(reDefaultDoc, ''), locHrefNoHash = locHref.replace(reHash, ''); this.$root.find('a').each(function() { var href = this.href.replace(reDefaultDoc, ''), $this = $(this); if (href == locHref || href == locHrefNoHash) { $this.addClass('current'); if (self.opts.markCurrentTree) { $this.parentsUntil('[data-smartmenus-id]', 'ul').each(function() { $(this).dataSM('parent-a').addClass('current'); }); } } }); } // save initial state this.wasCollapsible = this.isCollapsible(); }, destroy: function(refresh) { if (!refresh) { var eNS = '.smartmenus'; this.$root .removeData('smartmenus') .removeAttr('data-smartmenus-id') .removeDataSM('level') .unbind(eNS) .undelegate(eNS); eNS += this.rootId; $(document).unbind(eNS); $(window).unbind(eNS); if (this.opts.subIndicators) { this.$subArrow = null; } } this.menuHideAll(); var self = this; this.$root.find('ul').each(function() { var $this = $(this); if ($this.dataSM('scroll-arrows')) { $this.dataSM('scroll-arrows').remove(); } if ($this.dataSM('shown-before')) { if (self.opts.subMenusMinWidth || self.opts.subMenusMaxWidth) { $this.css({ width: '', minWidth: '', maxWidth: '' }).removeClass('sm-nowrap'); } if ($this.dataSM('scroll-arrows')) { $this.dataSM('scroll-arrows').remove(); } $this.css({ zIndex: '', top: '', left: '', marginLeft: '', marginTop: '', display: '' }); } if (($this.attr('id') || '').indexOf(self.accessIdPrefix) == 0) { $this.removeAttr('id'); } }) .removeDataSM('in-mega') .removeDataSM('shown-before') .removeDataSM('ie-shim') .removeDataSM('scroll-arrows') .removeDataSM('parent-a') .removeDataSM('level') .removeDataSM('beforefirstshowfired') .removeAttr('role') .removeAttr('aria-hidden') .removeAttr('aria-labelledby') .removeAttr('aria-expanded'); this.$root.find('a.has-submenu').each(function() { var $this = $(this); if ($this.attr('id').indexOf(self.accessIdPrefix) == 0) { $this.removeAttr('id'); } }) .removeClass('has-submenu') .removeDataSM('sub') .removeAttr('aria-haspopup') .removeAttr('aria-controls') .removeAttr('aria-expanded') .closest('li').removeDataSM('sub'); if (this.opts.subIndicators) { this.$root.find('span.sub-arrow').remove(); } if (this.opts.markCurrentItem) { this.$root.find('a.current').removeClass('current'); } if (!refresh) { this.$root = null; this.$firstLink = null; this.$firstSub = null; if (this.$disableOverlay) { this.$disableOverlay.remove(); this.$disableOverlay = null; } menuTrees.splice($.inArray(this, menuTrees), 1); } }, disable: function(noOverlay) { if (!this.disabled) { this.menuHideAll(); // display overlay over the menu to prevent interaction if (!noOverlay && !this.opts.isPopup && this.$root.is(':visible')) { var pos = this.$root.offset(); this.$disableOverlay = $('
').css({ position: 'absolute', top: pos.top, left: pos.left, width: this.$root.outerWidth(), height: this.$root.outerHeight(), zIndex: this.getStartZIndex(true), opacity: 0 }).appendTo(document.body); } this.disabled = true; } }, docClick: function(e) { if (this.$touchScrollingSub) { this.$touchScrollingSub = null; return; } // hide on any click outside the menu or on a menu link if (this.visibleSubMenus.length && !$.contains(this.$root[0], e.target) || $(e.target).is('a')) { this.menuHideAll(); } }, docTouchEnd: function(e) { if (!this.lastTouch) { return; } if (this.visibleSubMenus.length && (this.lastTouch.x2 === undefined || this.lastTouch.x1 == this.lastTouch.x2) && (this.lastTouch.y2 === undefined || this.lastTouch.y1 == this.lastTouch.y2) && (!this.lastTouch.target || !$.contains(this.$root[0], this.lastTouch.target))) { if (this.hideTimeout) { clearTimeout(this.hideTimeout); this.hideTimeout = 0; } // hide with a delay to prevent triggering accidental unwanted click on some page element var self = this; this.hideTimeout = setTimeout(function() { self.menuHideAll(); }, 350); } this.lastTouch = null; }, docTouchMove: function(e) { if (!this.lastTouch) { return; } var touchPoint = e.originalEvent.touches[0]; this.lastTouch.x2 = touchPoint.pageX; this.lastTouch.y2 = touchPoint.pageY; }, docTouchStart: function(e) { var touchPoint = e.originalEvent.touches[0]; this.lastTouch = { x1: touchPoint.pageX, y1: touchPoint.pageY, target: touchPoint.target }; }, enable: function() { if (this.disabled) { if (this.$disableOverlay) { this.$disableOverlay.remove(); this.$disableOverlay = null; } this.disabled = false; } }, getClosestMenu: function(elm) { var $closestMenu = $(elm).closest('ul'); while ($closestMenu.dataSM('in-mega')) { $closestMenu = $closestMenu.parent().closest('ul'); } return $closestMenu[0] || null; }, getHeight: function($elm) { return this.getOffset($elm, true); }, // returns precise width/height float values getOffset: function($elm, height) { var old; if ($elm.css('display') == 'none') { old = { position: $elm[0].style.position, visibility: $elm[0].style.visibility }; $elm.css({ position: 'absolute', visibility: 'hidden' }).show(); } var box = $elm[0].getBoundingClientRect && $elm[0].getBoundingClientRect(), val = box && (height ? box.height || box.bottom - box.top : box.width || box.right - box.left); if (!val && val !== 0) { val = height ? $elm[0].offsetHeight : $elm[0].offsetWidth; } if (old) { $elm.hide().css(old); } return val; }, getStartZIndex: function(root) { var zIndex = parseInt(this[root ? '$root' : '$firstSub'].css('z-index')); if (!root && isNaN(zIndex)) { zIndex = parseInt(this.$root.css('z-index')); } return !isNaN(zIndex) ? zIndex : 1; }, getTouchPoint: function(e) { return e.touches && e.touches[0] || e.changedTouches && e.changedTouches[0] || e; }, getViewport: function(height) { var name = height ? 'Height' : 'Width', val = document.documentElement['client' + name], val2 = window['inner' + name]; if (val2) { val = Math.min(val, val2); } return val; }, getViewportHeight: function() { return this.getViewport(true); }, getViewportWidth: function() { return this.getViewport(); }, getWidth: function($elm) { return this.getOffset($elm); }, handleEvents: function() { return !this.disabled && this.isCSSOn(); }, handleItemEvents: function($a) { return this.handleEvents() && !this.isLinkInMegaMenu($a); }, isCollapsible: function() { return this.$firstSub.css('position') == 'static'; }, isCSSOn: function() { return this.$firstLink.css('display') == 'block'; }, isFixed: function() { var isFixed = this.$root.css('position') == 'fixed'; if (!isFixed) { this.$root.parentsUntil('body').each(function() { if ($(this).css('position') == 'fixed') { isFixed = true; return false; } }); } return isFixed; }, isLinkInMegaMenu: function($a) { return $(this.getClosestMenu($a[0])).hasClass('mega-menu'); }, isTouchMode: function() { return !mouse || this.opts.noMouseOver || this.isCollapsible(); }, itemActivate: function($a, focus) { var $ul = $a.closest('ul'), level = $ul.dataSM('level'); // if for some reason the parent item is not activated (e.g. this is an API call to activate the item), activate all parent items first if (level > 1 && (!this.activatedItems[level - 2] || this.activatedItems[level - 2][0] != $ul.dataSM('parent-a')[0])) { var self = this; $($ul.parentsUntil('[data-smartmenus-id]', 'ul').get().reverse()).add($ul).each(function() { self.itemActivate($(this).dataSM('parent-a')); }); } // hide any visible deeper level sub menus if (!this.isCollapsible() || focus) { this.menuHideSubMenus(!this.activatedItems[level - 1] || this.activatedItems[level - 1][0] != $a[0] ? level - 1 : level); } // save new active item for this level this.activatedItems[level - 1] = $a; if (this.$root.triggerHandler('activate.smapi', $a[0]) === false) { return; } // show the sub menu if this item has one var $sub = $a.dataSM('sub'); if ($sub && (this.isTouchMode() || (!this.opts.showOnClick || this.clickActivated))) { this.menuShow($sub); } }, itemBlur: function(e) { var $a = $(e.currentTarget); if (!this.handleItemEvents($a)) { return; } this.$root.triggerHandler('blur.smapi', $a[0]); }, itemClick: function(e) { var $a = $(e.currentTarget); if (!this.handleItemEvents($a)) { return; } if (this.$touchScrollingSub && this.$touchScrollingSub[0] == $a.closest('ul')[0]) { this.$touchScrollingSub = null; e.stopPropagation(); return false; } if (this.$root.triggerHandler('click.smapi', $a[0]) === false) { return false; } var subArrowClicked = $(e.target).is('span.sub-arrow'), $sub = $a.dataSM('sub'), firstLevelSub = $sub ? $sub.dataSM('level') == 2 : false; // if the sub is not visible if ($sub && !$sub.is(':visible')) { if (this.opts.showOnClick && firstLevelSub) { this.clickActivated = true; } // try to activate the item and show the sub this.itemActivate($a); // if "itemActivate" showed the sub, prevent the click so that the link is not loaded // if it couldn't show it, then the sub menus are disabled with an !important declaration (e.g. via mobile styles) so let the link get loaded if ($sub.is(':visible')) { this.focusActivated = true; return false; } } else if (this.isCollapsible() && subArrowClicked) { this.itemActivate($a); this.menuHide($sub); return false; } if (this.opts.showOnClick && firstLevelSub || $a.hasClass('disabled') || this.$root.triggerHandler('select.smapi', $a[0]) === false) { return false; } }, itemDown: function(e) { var $a = $(e.currentTarget); if (!this.handleItemEvents($a)) { return; } $a.dataSM('mousedown', true); }, itemEnter: function(e) { var $a = $(e.currentTarget); if (!this.handleItemEvents($a)) { return; } if (!this.isTouchMode()) { if (this.showTimeout) { clearTimeout(this.showTimeout); this.showTimeout = 0; } var self = this; this.showTimeout = setTimeout(function() { self.itemActivate($a); }, this.opts.showOnClick && $a.closest('ul').dataSM('level') == 1 ? 1 : this.opts.showTimeout); } this.$root.triggerHandler('mouseenter.smapi', $a[0]); }, itemFocus: function(e) { var $a = $(e.currentTarget); if (!this.handleItemEvents($a)) { return; } // fix (the mousedown check): in some browsers a tap/click produces consecutive focus + click events so we don't need to activate the item on focus if (this.focusActivated && (!this.isTouchMode() || !$a.dataSM('mousedown')) && (!this.activatedItems.length || this.activatedItems[this.activatedItems.length - 1][0] != $a[0])) { this.itemActivate($a, true); } this.$root.triggerHandler('focus.smapi', $a[0]); }, itemLeave: function(e) { var $a = $(e.currentTarget); if (!this.handleItemEvents($a)) { return; } if (!this.isTouchMode()) { $a[0].blur(); if (this.showTimeout) { clearTimeout(this.showTimeout); this.showTimeout = 0; } } $a.removeDataSM('mousedown'); this.$root.triggerHandler('mouseleave.smapi', $a[0]); }, menuHide: function($sub) { if (this.$root.triggerHandler('beforehide.smapi', $sub[0]) === false) { return; } $sub.stop(true, true); if ($sub.css('display') != 'none') { var complete = function() { // unset z-index $sub.css('z-index', ''); }; // if sub is collapsible (mobile view) if (this.isCollapsible()) { if (this.opts.collapsibleHideFunction) { this.opts.collapsibleHideFunction.call(this, $sub, complete); } else { $sub.hide(this.opts.collapsibleHideDuration, complete); } } else { if (this.opts.hideFunction) { this.opts.hideFunction.call(this, $sub, complete); } else { $sub.hide(this.opts.hideDuration, complete); } } // remove IE iframe shim if ($sub.dataSM('ie-shim')) { $sub.dataSM('ie-shim').remove().css({ '-webkit-transform': '', transform: '' }); } // deactivate scrolling if it is activated for this sub if ($sub.dataSM('scroll')) { this.menuScrollStop($sub); $sub.css({ 'touch-action': '', '-ms-touch-action': '', '-webkit-transform': '', transform: '' }) .unbind('.smartmenus_scroll').removeDataSM('scroll').dataSM('scroll-arrows').hide(); } // unhighlight parent item + accessibility $sub.dataSM('parent-a').removeClass('highlighted').attr('aria-expanded', 'false'); $sub.attr({ 'aria-expanded': 'false', 'aria-hidden': 'true' }); var level = $sub.dataSM('level'); this.activatedItems.splice(level - 1, 1); this.visibleSubMenus.splice($.inArray($sub, this.visibleSubMenus), 1); this.$root.triggerHandler('hide.smapi', $sub[0]); } }, menuHideAll: function() { if (this.showTimeout) { clearTimeout(this.showTimeout); this.showTimeout = 0; } // hide all subs // if it's a popup, this.visibleSubMenus[0] is the root UL var level = this.opts.isPopup ? 1 : 0; for (var i = this.visibleSubMenus.length - 1; i >= level; i--) { this.menuHide(this.visibleSubMenus[i]); } // hide root if it's popup if (this.opts.isPopup) { this.$root.stop(true, true); if (this.$root.is(':visible')) { if (this.opts.hideFunction) { this.opts.hideFunction.call(this, this.$root); } else { this.$root.hide(this.opts.hideDuration); } // remove IE iframe shim if (this.$root.dataSM('ie-shim')) { this.$root.dataSM('ie-shim').remove(); } } } this.activatedItems = []; this.visibleSubMenus = []; this.clickActivated = false; this.focusActivated = false; // reset z-index increment this.zIndexInc = 0; this.$root.triggerHandler('hideAll.smapi'); }, menuHideSubMenus: function(level) { for (var i = this.activatedItems.length - 1; i >= level; i--) { var $sub = this.activatedItems[i].dataSM('sub'); if ($sub) { this.menuHide($sub); } } }, menuIframeShim: function($ul) { // create iframe shim for the menu if (IE && this.opts.overlapControlsInIE && !$ul.dataSM('ie-shim')) { $ul.dataSM('ie-shim', $('