stacked-menu.js

/**
 * 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