/**
* A flexible stacked navigation menu.
* @class
*
* @example <caption>The StackedMenu basic template looks like:</caption>
* <div id="stacked-menu" class="stacked-menu">
* <nav class="menu">
* <li class="menu-item">
* <a href="home.html" class="menu-link">
* <i class="menu-icon fa fa-home"></i>
* <span class="menu-text">Home</span>
* <span class="badge badge-danger">9+</span>
* </a>
* <li>
* </nav>
* </div>
*
* @example <caption>Instance the StackedMenu:</caption>
* var menus = new StackedMenu();
*/
class StackedMenu {
/**
* Create a StackedMenu.
* @constructor
* @param {Object} options - An object containing key:value that representing the current StackedMenu.
*/
constructor(options) {
/**
* The StackedMenu options.
* @type {Object}
* @property {Boolean} compact=false - Transform StackedMenu items (except item childs) to small size.
* @property {Boolean} hoverable=false - How StackedMenu triggered `open`/`close` state. Use `false` for hoverable and `true` for collapsible (clickable).
* @property {Boolean} closeOther=true - Control whether expanding an item will cause the other items to close. Only available when `hoverable=false`.
* @property {String} align='left' - Where StackedMenu items childs will open when `hoverable=true` (`left`/`right`).
* @property {String} selector='#stacked-menu' - The StackedMenu element selector.
* @property {String} selectorClass='stacked-menu' - The css class name that will be added to the StackedMenu and used for css prefix classes.
* @example
* var options = {
* closeOther: false,
* align: 'right',
* };
*
* var menus = new StackedMenu(options);
*/
this.options = {
compact: false,
hoverable: false,
closeOther: true,
align: 'right',
selector: '#stacked-menu',
selectorClass: 'stacked-menu'
}
// mixed default and custom options
this.options = this._extend({}, this.options, options)
/**
* The StackedMenu element.
* @type {Element}
*/
this.selector = document.querySelector(this.options.selector)
/**
* The StackedMenu items.
* @type {Element}
*/
this.items = this.selector ? this.selector.querySelectorAll('.menu-item') : null
// forEach fallback
if (!Array.prototype.forEach) {
Array.prototype.forEach = function forEach(cb, arg) {
if(typeof cb !== 'function') throw new TypeError(`${cb} is not a function`)
let array = this
arg = arg || this
for(let i = 0; i < array.length; i++) {
cb.call(arg, array[i], i, array)
}
}
}
this.each = Array.prototype.forEach
/**
* Lists of feature classes that will be added to the StackedMenu depend to current options.
* Used selectorClass for prefix.
* @type {Object}
*/
this.classes = {
alignLeft: this.options.selectorClass + '-has-left',
compact: this.options.selectorClass + '-has-compact',
collapsible: this.options.selectorClass + '-has-collapsible',
hoverable: this.options.selectorClass + '-has-hoverable',
hasChild: 'has-child',
hasActive: 'has-active',
hasOpen: 'has-open'
}
/** states element */
/**
* The active item.
* @type {Element}
*/
this.active = null
/**
* The open item(s).
* @type {Element}
*/
this.open = []
/** event handlers */
this.handlerClickDoc = []
this.handlerOver = []
this.handlerOut = []
this.handlerClick = []
// Initialization
this.init()
}
/** Private methods */
/**
* Listen on document when the page is ready.
* @private
* @ignore
* @param {Function} handler - The callback function when page is ready.
* @return {void}
*/
_onReady(handler) {
if(document.readyState != 'loading') {
handler()
} else {
document.addEventListener('DOMContentLoaded', handler, false)
}
}
/**
* Merge the contents of two or more objects together into the first object.
* @private
* @ignore
* @param {Object} obj - An object containing additional properties to merge in.
* @return {Object} The merged object.
*/
_extend(obj) {
obj = obj || {}
for (let i = 1; i < arguments.length; i++) {
if (!arguments[i]) continue
for (let key in arguments[i]) {
if (arguments[i].hasOwnProperty(key))
obj[key] = arguments[i][key]
}
}
return obj
}
/**
* Attach an event to StackedMenu selector.
* @private
* @ignore
* @param {String} type - The name of the event (case-insensitive).
* @param {(Boolean|Number|String|Array|Object)} data - The custom data that will be added to event.
* @return {void}
*/
_emit(type, data) {
// create a custom event
let e
if (document.createEvent) {
e = document.createEvent('Event')
e.initEvent(type, true, true)
} else {
e = document.createEventObject()
e.eventType = type
}
e.eventName = type
e.data = data || this
// attach event to selector
document.createEvent
? this.selector.dispatchEvent(e)
: this.selector.fireEvent('on' + type, e)
}
/**
* Bind one or two handlers to the element, to be executed when the mouse pointer enters and leaves the element.
* @private
* @ignore
* @param {Element} el - The target element.
* @param {Function} handlerOver - A function to execute when the mouse pointer enters the element.
* @param {Function} handlerOut - A function to execute when the mouse pointer leaves the element.
* @return {void}
*/
_hover(el, handlerOver, handlerOut = () => ({})) {
this._on(el, 'mouseover', handlerOver),
this._on(el, 'mouseout', handlerOut)
}
/**
* Registers the specified listener on the element.
* @private
* @ignore
* @param {Element} el - The target element.
* @param {String} type - The name of the event.
* @param {Function} handler - The callback function when event type is fired.
* @return {void}
*/
_on(el, type, handler) {
let types = type.split(' ')
for (let i = 0; i < types.length; i++) {
el[window.addEventListener ? 'addEventListener' : 'attachEvent']( window.addEventListener ? types[i] : `on${types[i]}` , handler, false)
}
}
/**
* Removes the event listener previously registered with [_on()]{@link StackedMenu#_on} method.
* @private
* @ignore
* @param {Element} el - The target element.
* @param {String} type - The name of the event.
* @param {Function} handler - The callback function when event type is fired.
* @return {void}
*/
_off(el, type, handler) {
let types = type.split(' ')
for (let i = 0; i < types.length; i++) {
el[window.removeEventListener ? 'removeEventListener' : 'detachEvent']( window.removeEventListener ? types[i] : `on${types[i]}` , handler, false)
}
}
/**
* Adds one or more class names to the target element.
* @private
* @ignore
* @param {Element} el - The target element.
* @param {String} className - Specifies one or more class names to be added.
* @return {void}
*/
_addClass(el, className) {
let classes = className.split(' ')
for (let i = 0; i < classes.length; i++) {
if (el.classList) el.classList.add(classes[i])
else el.classes[i] += ' ' + classes[i]
}
}
/**
* Removes one or more class names to the target element.
* @private
* @ignore
* @param {Element} el - The target element.
* @param {String} className - Specifies one or more class names to be added.
* @return {void}
*/
_removeClass(el, className) {
let classes = className.split(' ')
for (let i = 0; i < classes.length; i++) {
if (el.classList) el.classList.remove(classes[i])
else el.classes[i] = el.classes[i].replace(new RegExp('(^|\\b)' + classes[i].split(' ').join('|') + '(\\b|$)', 'gi'), ' ')
}
}
/**
* Determine whether the element is assigned the given class.
* @private
* @ignore
* @param {Element} el - The target element.
* @param {String} className - The class name to search for.
* @return {void}
*/
_hasClass(el, className) {
if (el.classList) return el.classList.contains(className)
return new RegExp('(^| )' + className + '( |$)', 'gi').test(el.className)
}
/**
* Determine whether the element is a menu child.
* @private
* @ignore
* @param {Element} el - The target element.
* @return {void}
*/
_hasChild(el) {
return this._hasClass(el, this.classes.hasChild)
}
/**
* Determine whether the element is a active menu.
* @private
* @ignore
* @param {Element} el - The target element.
* @return {void}
*/
_hasActive(el) {
return this._hasClass(el, this.classes.hasActive)
}
/**
* Determine whether the element is a open menu.
* @private
* @ignore
* @param {Element} el - The target element.
* @return {void}
*/
_hasOpen(el) {
return this._hasClass(el, this.classes.hasOpen)
}
/**
* Attach an event to menu item depend on hoverable option.
* @private
* @ignore
* @param {Element} el - The target element.
* @param {Number} index - An array index from each menu item use to detach the current event.
* @return {void}
*/
_menuTrigger(el, index) {
// remove exist listener
this._off(el, 'mouseover', this.handlerOver[index])
this._off(el, 'mouseout', this.handlerOut[index])
this._off(el, 'click', this.handlerClick[index])
// handler listener
this.handlerOver[index] = this.openMenu.bind(this, el)
this.handlerOut[index] = this.closeMenu.bind(this, el)
this.handlerClick[index] = this.toggleMenu.bind(this, el)
// add listener
if (this.isHoverable()) {
this._hover(el, this.handlerOver[index], this.handlerOut[index])
} else {
this._on(el, 'click', this.handlerClick[index])
}
}
/**
* Handle for menu items interactions.
* @private
* @ignore
* @param {Element} items - The element of menu items.
* @return {void}
*/
_handleInteractions(items) {
let self = this
// remove exist listener first
this._off(document.body, 'click', this.handlerClickDoc)
// close on outside click, only on collapsible with compact mode
if (!this.isHoverable() && this.isCompact()) {
// handle listener
this.handlerClickDoc = this.closeAllMenu.bind(this)
// add listener
this._on(document.body, 'click', this.handlerClickDoc)
}
this.each.call(items, (el, i) => {
if (self._hasChild(el)) {
self._menuTrigger(el, i)
}
// stop propagation on each menu-link
self._on(el, 'click', function(e) {
e.stopPropagation()
// prevent default if has child
if (self._hasChild(e.target.parentNode)) e.preventDefault()
})
if(self._hasActive(el)) this.active = el
})
}
/**
* Get the parent menu item text of menu to be use on menu subhead.
* @private
* @ignore
* @param {Element} el - The target element.
* @return {void}
*/
_getSubhead(el) {
return el.querySelector('.menu-text').textContent
}
/**
* Generate the subhead element for each child menu.
* @private
* @ignore
* @return {void}
*/
_generateSubhead() {
let self = this
let menus = this.selector.children
let link, menu, subhead, label
this.each.call(menus, el => {
self.each.call(el.children, child => {
if (self._hasChild(child)) {
self.each.call(child.children, cc => {
if(self._hasClass(cc, 'menu-link')) link = cc
})
menu = link.nextElementSibling
subhead = document.createElement('li')
label = document.createTextNode(self._getSubhead(link))
subhead.appendChild(label)
self._addClass(subhead, 'menu-subhead')
menu.insertBefore(subhead, menu.firstChild)
}
})
})
}
/** Public methods */
/**
* The first process that called after constructs the StackedMenu instance.
* @public
* @fires StackedMenu#menu:init
* @return {void}
*/
init() {
let opts = this.options
this._addClass(this.selector, opts.selectorClass)
// generate subhead
this._generateSubhead()
// implement compact feature
this.compact(opts.compact)
// implement hoverable feature
this.hoverable(opts.hoverable)
// on ready state
this._onReady(() => {
/**
* This event is fired when the Menu has completed init.
*
* @event StackedMenu#menu:init
* @type {Object}
* @property {Object} data - The StackedMenu data instance.
*
* @example
* document.querySelector('#stacked-menu').addEventListener('menu:init', function(e) {
* console.log(e.data);
* });
* @example <caption>Or using jQuery:</caption>
* $('#stacked-menu').on('menu:init', function() {
* console.log('fired on menu:init!!');
* });
*/
this._emit('menu:init')
})
}
/**
* Open/show the target menu item. This method didn't take effect to an active item if not on compact mode.
* @public
* @fires StackedMenu#menu:open
* @param {Element} el - The target element.
* @return {Object} The StackedMenu instance.
*
* @example
* var menuItem2 = menu.querySelectorAll('.menu-item.has-child')[1];
* menu.openMenu(menuItem2);
*/
openMenu(el) {
// blocked open an active item if not on compact mode
if(this._hasActive(el) && !this.isCompact()) return
this._addClass(el, this.classes.hasOpen)
this.open.push(el)
// child menu behavior
if (this.isHoverable() || (this.isCompact() && !this.hoverable())) {
const clientHeight = document.documentElement.clientHeight
const child = el.querySelector('.menu')
const pos = child.getBoundingClientRect()
const tolerance = pos.height - 20
const bottom = clientHeight - pos.top
if (pos.top >= 500 || tolerance >= bottom) {
child.style.top = 'auto'
child.style.bottom = 0
}
}
/**
* This event is fired when the Menu has open.
*
* @event StackedMenu#menu:open
* @type {Object}
* @property {Object} data - The StackedMenu data instance.
*
* @example
* document.querySelector('#stacked-menu').addEventListener('menu:open', function(e) {
* console.log(e.data);
* });
* @example <caption>Or using jQuery:</caption>
* $('#stacked-menu').on('menu:open', function() {
* console.log('fired on menu:open!!');
* });
*/
this._emit('menu:open')
return this
}
/**
* Close/hide the target menu item.
* @public
* @fires StackedMenu#menu:close
* @param {Element} el - The target element.
* @return {Object} The StackedMenu instance.
*
* @example
* var menuItem2 = menu.querySelectorAll('.menu-item.has-child')[1];
* menu.closeMenu(menuItem2);
*/
closeMenu(el) {
this._removeClass(el, this.classes.hasOpen)
this.each.call(this.open, (v, i) => {
if (el == v) this.open.splice(i, 1)
})
// remove child menu behavior style
if (this.isHoverable() || (this.isCompact() && !this.hoverable())) {
const child = el.querySelector('.menu')
child.style.top = ''
child.style.bottom = ''
}
/**
* This event is fired when the Menu has close.
*
* @event StackedMenu#menu:close
* @type {Object}
* @property {Object} data - The StackedMenu data instance.
*
* @example
* document.querySelector('#stacked-menu').addEventListener('menu:close', function(e) {
* console.log(e.data);
* });
* @example <caption>Or using jQuery:</caption>
* $('#stacked-menu').on('menu:close', function() {
* console.log('fired on menu:close!!');
* });
*/
this._emit('menu:close')
return this
}
/**
* Close all opened menu items.
* @public
* @fires StackedMenu#menu:close
* @return {Object} The StackedMenu instance.
*
* @example
* menu.closeAllMenu();
*/
closeAllMenu() {
let self = this
this.each.call(this.items, el => {
if (self._hasOpen(el)) {
self.closeMenu(el)
}
})
return this
}
/**
* Toggle open/close the target menu item.
* @public
* @fires StackedMenu#menu:open
* @fires StackedMenu#menu:close
* @param {Element} el - The target element.
* @return {Object} The StackedMenu instance.
*
* @example
* var menuItem2 = menu.querySelectorAll('.menu-item.has-child')[1];
* menu.toggleMenu(menuItem2);
*/
toggleMenu(el) {
const method = this._hasOpen(el) ? 'closeMenu': 'openMenu'
let self = this
let itemParent, elParent
// close other
this.each.call(this.items, item => {
itemParent = item.parentNode.parentNode
elParent = el.parentNode.parentNode
// close other except parents that has open state and an active item
if(!self._hasOpen(elParent) && self._hasChild(itemParent)) {
if (self.options.closeOther || (!self.options.closeOther && self.isCompact())) {
self.closeMenu(itemParent)
}
}
})
// open target el
if (this._hasChild(el)) this[method](el)
return this
}
/**
* Set the open menu position to `left` or `right`.
* @public
* @fires StackedMenu#menu:align
* @param {String} position - The position that will be set to the Menu.
* @return {Object} The StackedMenu instance.
*
* @example
* menu.align('left');
*/
align(position) {
const method = (position === 'left') ? '_addClass': '_removeClass'
const classes = this.classes
this[method](this.selector, classes.alignLeft)
this.options.align = position
/**
* This event is fired when the Menu has changed align position.
*
* @event StackedMenu#menu:align
* @type {Object}
* @property {Object} data - The StackedMenu data instance.
*
* @example
* document.querySelector('#stacked-menu').addEventListener('menu:align', function(e) {
* console.log(e.data);
* });
* @example <caption>Or using jQuery:</caption>
* $('#stacked-menu').on('menu:align', function() {
* console.log('fired on menu:align!!');
* });
*/
this._emit('menu:align')
return this
}
/**
* Determine whether the Menu is currently compact.
* @public
* @return {Boolean} is compact.
*
* @example
* var isCompact = menu.isCompact();
*/
isCompact() {
return this.options.compact
}
/**
* Toggle the Menu compact mode.
* @public
* @fires StackedMenu#menu:compact
* @param {Boolean} isCompact - The compact mode.
* @return {Object} The StackedMenu instance.
*
* @example
* menu.compact(true);
*/
compact(isCompact) {
const method = (isCompact) ? '_addClass': '_removeClass'
const classes = this.classes
this[method](this.selector, classes.compact)
this.options.compact = isCompact
// reset interactions
this._handleInteractions(this.items)
/**
* This event is fired when the Menu has completed toggle compact mode.
*
* @event StackedMenu#menu:compact
* @type {Object}
* @property {Object} data - The StackedMenu data instance.
*
* @example
* document.querySelector('#stacked-menu').addEventListener('menu:compact', function(e) {
* console.log(e.data);
* });
* @example <caption>Or using jQuery:</caption>
* $('#stacked-menu').on('menu:compact', function() {
* console.log('fired on menu:compact!!');
* });
*/
this._emit('menu:compact')
return this
}
/**
* Determine whether the Menu is currently hoverable.
* @public
* @return {Boolean} is hoverable.
*
* @example
* var isHoverable = menu.isHoverable();
*/
isHoverable() {
return this.options.hoverable
}
/**
* Toggle the Menu (interaction) hoverable.
* @public
* @fires StackedMenu#menu:hoverable
* @param {Boolean} isHoverable - `true` for hoverable and `false` for collapsible (clickable).
* @return {Object} The StackedMenu instance.
*
* @example
* menu.hoverable(true);
*/
hoverable(isHoverable) {
const classes = this.classes
if (isHoverable) {
this._addClass(this.selector, classes.hoverable)
this._removeClass(this.selector, classes.collapsible)
} else {
this._addClass(this.selector, classes.collapsible)
this._removeClass(this.selector, classes.hoverable)
}
this.options.hoverable = isHoverable
// reset interactions
this._handleInteractions(this.items)
/**
* This event is fired when the Menu has completed toggle hoverable.
*
* @event StackedMenu#menu:hoverable
* @type {Object}
* @property {Object} data - The StackedMenu data instance.
*
* @example
* document.querySelector('#stacked-menu').addEventListener('menu:hoverable', function(e) {
* console.log(e.data);
* });
* @example <caption>Or using jQuery:</caption>
* $('#stacked-menu').on('menu:hoverable', function() {
* console.log('fired on menu:hoverable!!');
* });
*/
this._emit('menu:hoverable')
return this
}
}
export default StackedMenu