gitcoinco/code_fund_ads

View on GitHub
app/javascript/themes/current/components/stacked-menu.js

Summary

Maintainability
F
3 days
Test Coverage
/**
 * 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();
 */
export default 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 = []

    /**
     * The StackedMenu element.
     * @type {Boolean}
     */
    this.turbolinksAvailable =
      typeof window.Turbolinks === 'object' && window.Turbolinks.supported

    /** event handlers */
    this.handlerClickDoc = []
    this.handlerOver = []
    this.handlerOut = []
    this.handlerClick = []

    // Initialization
    this.init()
  }

  /** Private methods */
  /**
   * Listen on document when the page is ready.
   * @private
   * @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)
    }
  }

  /**
   * Handles clicking on menu leaves. Turbolinks friendly.
   * @private
   * @param  {Object} self - The StackedMenu self instance.
   * @return {void}
   */
  _handleNavigation (self) {
    self.each.call(this.items, el => {
      self._on(el, 'click', function (e) {
        // Stop propagating the event to parent links
        e.stopPropagation()
        // if Turbolinks are available preventDefault immediatelly.
        self.turbolinksAvailable ? e.preventDefault() : null
        // if the element is "parent" and Turbolinks are not available,
        // maintain the original behaviour. Otherwise navigate programmatically
        if (self._hasChild(el)) {
          self.turbolinksAvailable ? null : e.preventDefault()
        } else {
          self.turbolinksAvailable
            ? window.Turbolinks.visit(el.firstElementChild.href)
            : null
        }
      })
    })
  }

  /**
   * Merge the contents of two or more objects together into the first object.
   * @private
   * @param  {Object} obj - An object containing additional properties to merge in.
   * @return {Object} The merged object.
   */
  _extend (obj) {
    obj = obj || {}
    const args = arguments
    for (let i = 1; i < args.length; i++) {
      if (!args[i]) continue
      for (let key in args[i]) {
        if (args[i].hasOwnProperty(key)) obj[key] = args[i][key]
      }
    }
    return obj
  }

  /**
   * Attach an event to StackedMenu selector.
   * @private
   * @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) {
    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
   * @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) {
    if (el.tagName === 'A') {
      this._on(el, 'focus', handlerOver)
      this._on(el, 'blur', handlerOut)
    } else {
      this._on(el, 'mouseover', handlerOver)
      this._on(el, 'mouseout', handlerOut)
    }
  }

  /**
   * Registers the specified listener on the element.
   * @private
   * @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
   * @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
   * @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
   * @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
   * @param {Element} el - The target element.
   * @param {String} className - The class name to search for.
   * @return {Boolean} is has className.
   */
  _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
   * @param {Element} el - The target element.
   * @return {Boolean} is has child.
   */
  _hasChild (el) {
    return this._hasClass(el, this.classes.hasChild)
  }

  /**
   * Determine whether the element is a active menu.
   * @private
   * @param {Element} el - The target element.
   * @return {Boolean} is has active state.
   */
  _hasActive (el) {
    return this._hasClass(el, this.classes.hasActive)
  }

  /**
   * Determine whether the element is a open menu.
   * @private
   * @param {Element} el - The target element.
   * @return {Boolean} is has open state.
   */
  _hasOpen (el) {
    return this._hasClass(el, this.classes.hasOpen)
  }

  /**
   * Determine whether the element is a level menu.
   * @private
   * @param {Element} el - The target element.
   * @return {Boolean} is a level menu.
   */
  _isLevelMenu (el) {
    return this._hasClass(el.parentNode.parentNode, this.options.selectorClass)
  }

  /**
   * Attach an event to menu item depend on hoverable option.
   * @private
   * @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) {
    let elHover = el.querySelector('a')

    // remove exist listener
    this._off(el, 'mouseover', this.handlerOver[index])
    this._off(el, 'mouseout', this.handlerOut[index])
    this._off(elHover, 'focus', this.handlerOver[index])
    this._off(elHover, 'blur', 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()) {
      if (this._hasChild(el)) {
        this._hover(el, this.handlerOver[index], this.handlerOut[index])
        this._hover(elHover, this.handlerOver[index], this.handlerOut[index])
      }
    } else {
      this._on(el, 'click', this.handlerClick[index])
    }
  }

  /**
   * Handle for menu items interactions.
   * @private
   * @param {Element} items - The element of menu items.
   * @return {void}
   */
  _handleInteractions (items) {
    const self = this

    this.each.call(items, (el, i) => {
      if (self._hasChild(el)) {
        self._menuTrigger(el, i)
      }

      if (self._hasActive(el)) self.active = el
    })
  }

  /**
   * Get the parent menu item text of menu to be use on menu subhead.
   * @private
   * @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
   * @return {void}
   */
  _generateSubhead () {
    const 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)
        }
      })
    })
  }

  /**
   * Handle menu link tabindex depend on parent states.
   * @return {void}
   */
  _handleTabIndex () {
    const self = this
    this.each.call(this.items, el => {
      let container = el.parentNode.parentNode
      if (!self._isLevelMenu(el)) {
        el.querySelector('a').setAttribute('tabindex', '-1')
      }
      if (self._hasActive(container) || self._hasOpen(container)) {
        el.querySelector('a').removeAttribute('tabindex')
      }
    })
  }

  /**
   * Animate slide menu item.
   * @private
   * @param  {Object} el - The target element.
   * @param  {String} direction - Up/Down slide direction.
   * @param  {Number} speed - Animation Speed in millisecond.
   * @param  {String} easing - CSS Animation effect.
   * @return {Promise} resolve
   */
  _slide (el, direction, speed, easing) {
    speed = speed || 300
    easing = easing || 'ease'
    let self = this
    let menu = el.querySelector('.menu')
    let es = window.getComputedStyle(el)['height']
    // wait to resolve
    let walkSpeed = speed + 50
    // wait to clean style attribute
    let clearSpeed = walkSpeed + 100

    menu.style.transition = `height ${speed}ms ${easing}, opacity ${speed /
      2}ms ${easing}, visibility ${speed / 2}ms ${easing}`

    // slideDown
    if (direction === 'down') {
      // element
      el.style.overflow = 'hidden'
      el.style.height = es
      // menu
      menu.style.height = 'auto'
      // get the current menu height
      let height = window.getComputedStyle(menu)['height']
      menu.style.height = 0
      menu.style.visibility = 'hidden'
      menu.style.opacity = 0
      // remove element style
      el.style.overflow = ''
      el.style.height = ''

      setTimeout(function () {
        menu.style.height = height
        menu.style.opacity = 1
        menu.style.visibility = 'visible'
      }, 0)
    } else if (direction === 'up') {
      // get the menu height
      let height = window.getComputedStyle(menu)['height']
      menu.style.height = height
      menu.style.visibility = 'visible'
      menu.style.opacity = 1

      setTimeout(function () {
        menu.style.height = 0
        menu.style.visibility = 'hidden'
        menu.style.opacity = 0
      }, 0)
    }

    let done = new Promise(function (resolve) {
      // remove the temporary styles
      setTimeout(function () {
        resolve(el)
        // emit event
        self._emit('menu:slide' + direction)
      }, walkSpeed)
    })

    // remove styles after done has resolve
    setTimeout(function () {
      menu.removeAttribute('style')
    }, clearSpeed)

    return done
  }

  /** Public methods */
  /**
   * The first process that called after constructs the StackedMenu instance.
   * @public
   * @fires StackedMenu#menu:init
   * @return {void}
   */
  init () {
    const self = this
    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)

    // handle menu link tabindex
    this._handleTabIndex()

    // handle menu click with or without Turbolinks
    this._handleNavigation(self)

    // close on outside click, only on collapsible with compact mode
    this._on(document.body, 'click', function () {
      if (!self.isHoverable() && self.isCompact()) {
        // handle listener
        self.closeAllMenu()
      }
    })

    // 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!!');
       * });
       */
      self._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.
   * @param {Boolean} emiter - are the element will fire menu:open or not.
   * @return {Object} The StackedMenu instance.
   *
   * @example
   * var menuItem2 = menu.querySelectorAll('.menu-item.has-child')[1];
   * menu.openMenu(menuItem2);
   */
  openMenu (el, emiter = true) {
    // prevent open on active item if not on compact mode
    if (this._hasActive(el) && !this.isCompact()) return
    const self = this
    let blockedSlide = this._isLevelMenu(el) && this.isCompact()

    // open menu
    if (this.isHoverable() || blockedSlide) {
      this._addClass(el, this.classes.hasOpen)
      // handle tabindex
      this._handleTabIndex()
    } else {
      // slide down
      this._slide(el, 'down', 150, 'linear').then(function () {
        self._addClass(el, self.classes.hasOpen)
        // handle tabindex
        self._handleTabIndex()
      })
    }

    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
      const transformOriginX = this.options.align === 'left' ? '100%' : '0px'

      if (pos.top >= 500 || tolerance >= bottom) {
        child.style.top = 'auto'
        child.style.bottom = 0
        child.style.transformOrigin = `${transformOriginX} 100% 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!!');
     * });
     */
    if (emiter) {
      this._emit('menu:open')
    }

    return this
  }

  /**
   * Close/hide the target menu item.
   * @public
   * @fires StackedMenu#menu:close
   * @param {Element} el - The target element.
   * @param {Boolean} emiter - are the element will fire menu:open or not.
   * @return {Object} The StackedMenu instance.
   *
   * @example
   * var menuItem2 = menu.querySelectorAll('.menu-item.has-child')[1];
   * menu.closeMenu(menuItem2);
   */
  closeMenu (el, emiter = true) {
    const self = this
    let blockedSlide = this._isLevelMenu(el) && this.isCompact()
    // open menu
    if (this.isHoverable() || blockedSlide) {
      this._removeClass(el, this.classes.hasOpen)
      // handle tabindex
      this._handleTabIndex()
    } else {
      if (!this._hasActive(el)) {
        // slide up
        this._slide(el, 'up', 150, 'linear').then(function () {
          self._removeClass(el, self.classes.hasOpen)
          // handle tabindex
          self._handleTabIndex()
        })
      }
    }

    this.each.call(this.open, (v, i) => {
      if (el == v) self.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 = ''
      child.style.transformOrigin = ''
    }

    /**
     * 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!!');
     * });
     */
    if (emiter) {
      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 () {
    const self = this
    this.each.call(this.items, el => {
      if (self._hasOpen(el)) {
        self.closeMenu(el, false)
      }
    })

    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'
    const self = this
    let itemParent, elParent

    // close other
    this.each.call(this.items, item => {
      itemParent = item.parentNode.parentNode
      itemParent = self._hasClass(itemParent, 'menu-item')
        ? itemParent
        : itemParent.parentNode
      elParent = el.parentNode.parentNode
      elParent = self._hasClass(elParent, 'menu-item')
        ? elParent
        : elParent.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())
        ) {
          if (self._hasOpen(itemParent)) {
            self.closeMenu(itemParent, false)
          }
        }
      }
    })
    // 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
  }
}