Source: components/buttons/_multiple-action-button.js

'use strict';

/**
 * Multiple action button component.
 * @namespace multiactionButtons
 * @memberof UCASDesignFramework
 * @param {object} global - UCASDesignFramework object.
 * @param {object} _u - UCASUtilities object.
 */
(function (global, _u) {
  var multiactionButtonContainer
  var keys = {
    tab: 9,
    enter: 13,
    esc: 27,
    space: 32,
    left: 37,
    up: 38,
    right: 39,
    down: 40
  }

  /**
   * Initialise plugin.
   * @function init
   * @memberof! UCASDesignFramework.multiactionButtons
   * @public
   * @param  {Node} [context=document] - DOM element we're limiting selection to
   * @param  {string} [selector='[data-multiaction-button]'] - CSS selector
   */
  function init (context, selector) {
    context = context || document
    selector = selector || '[data-multiaction-button]'
    multiactionButtonContainer = document.getElementById('multiactionButtonContainer')

    // Set an attribute on .layout-scope for IE.
    if (document.documentMode) {
      var scopes = context.querySelectorAll('.layout-scope')
      scopes.forEach(function (scope) {
        scope.setAttribute('data-layout-scope-legacy', '')
      })
    }

    // First, clean up as this may be being called in a SPA.
    cleanUp()
    // Add event listeners.
    prepareMultiactionButtons(context, selector)
  }

  /**
   * Destroy plugin.
   * @function destroy
   * @memberof! UCASDesignFramework.multiactionButtons
   * @public
   */
  function destroy () {
    cleanUp()
    document.removeEventListener('click', hideAll, false)
  }

  /**
   * Clean up any nodes which have been moved or made stray.
   * @function cleanUp
   * @memberof! UCASDesignFramework.multiactionButtons
   * @public
   */
  function cleanUp () {
    var items = document.querySelectorAll('#multiactionButtonContainer [data-multiaction-button]')
    items.forEach(function (item) {
      var trigger = document.querySelector('button[aria-controls="' + item.id + '"]')
      if (trigger) {
        // Move back to original place.
        trigger.parentNode.insertBefore(item, trigger.nextSibling)
      } else {
        // Delete as the trigger has disappeared.
        item.parentNode.removeChild(item)
      }
    })
  }

  /**
   * Prepare multiaction buttons.
   * @param {object} context - DOM element we're limiting selection to
   * @param {string} selector - CSS selector
   */
  function prepareMultiactionButtons (context, selector) {
    var items = context.querySelectorAll(selector)

    _u.forEach(items, function (i, el) {
      var id = el.getAttribute('id')
      if (id) {
        // Set initial state.
        el.setAttribute('aria-expanded', 'false')
        el.setAttribute('role', 'menu')
        // Add a trigger button if we don't have one already.
        if (el.parentNode.querySelector('[aria-controls="' + id + '"]') === null) {
          var button = document.createElement('button')
          var attributes = _u.json.getJson(el.getAttribute('data-multiaction-button'))
          _u.log.log('data-multiaction-button', attributes)

          var classes = 'button button--multiaction'
          classes += attributes.small ? ' button--small' : ''
          classes += attributes.hideLabel ? ' button--multiaction-hide-label' : ''
          classes += attributes.orientation ? ' button--multiaction-' + attributes.orientation : ''
          classes += attributes.type ? ' button--' + attributes.type : ' button--secondary'

          button.setAttribute('class', classes)
          button.setAttribute('aria-controls', id)
          button.appendChild(document.createTextNode(el.getAttribute('data-multiaction-button-label') || 'More'))
          button.addEventListener('click', multiactionButtonHandler, false)
          button.addEventListener('keydown', multiactionButtonHandler, false)
          button.addEventListener('keyup', multiactionButtonHandler, false)

          // Move the multiactions element into a wrapper to control layout.
          var wrapper = document.createElement('div')
          var wrapperClasses = 'button--multiaction-wrapper'
          wrapperClasses += attributes.position === 'left' ? ' button--multiaction-wrapper-left' : ''
          wrapperClasses += attributes.position === 'right' ? ' button--multiaction-wrapper-right' : ''
          wrapperClasses += attributes.compact === true ? ' button--multiaction-wrapper-compact' : ''
          wrapper.setAttribute('class', wrapperClasses)

          // Update the attributes for the individual action buttons.
          el.querySelectorAll('button').forEach(function (button) {
            button.setAttribute('tabindex', '-1')
            button.setAttribute('role', 'menuitem')
          })

          // Handle other keyboard interaction.
          el.addEventListener('keydown', multiactionButtonsKeydownHandler, false)
          el.addEventListener('keyup', multiactionButtonsKeydownHandler, false)

          el.parentNode.insertBefore(wrapper, el)
          wrapper.appendChild(button)
          wrapper.appendChild(el)

          // Check for disabled state.
          if (el.getAttribute('data-multiaction-button-disabled')) {
            disable(el)
          }
        }
      }
    })
  }

  /**
   * Event handler.
   * @param {Event} event - event object
   */
  function multiactionButtonHandler (event) {
    // https://www.w3.org/WAI/tutorials/menus/application-menus/#top-level-menu-items
    // The following are required:
    // return/enter ↵   Open the submenu, select first submenu item.
    // space
    // down ↓
    // up ↑             Open the submenu, select last submenu item.

    // NOTE There is a bug in Firefox that stops space behaving properly: https://bugzilla.mozilla.org/show_bug.cgi?id=1487102

    // Handling use of space on keyup and all other keys on keydown
    // as I had problems with space on keydown.
    if (event.type === 'click' ||
        (event.type === 'keyup' && event.keyCode === keys.space) ||
        (event.type === 'keydown' && [keys.enter, keys.down, keys.up].indexOf(event.keyCode) !== -1)) {
      event.stopPropagation()
      event.preventDefault()
      var id = event.target.getAttribute('aria-controls')
      toggle(id, event.keyCode)
    }
  }

  /**
   * Event handler.
   * @param {Event} event - event object
   */
  function multiactionButtonsKeydownHandler (event) {
    // https://www.w3.org/WAI/tutorials/menus/application-menus/#top-level-menu-items
    // tab ⇥            Close the submenu, select the next focusable element outside of the menu.
    // shift ⇧ + tab ⇥  Close the submenu, select the previous focusable element outside of the menu.
    // return/enter ↵   Carry out function of this item. (In this example: bring up a dialog box with the text of the chosen menu item.)
    // space
    // down ↓           Select next submenu item.
    // up ↑             Select previous submenu item.
    // esc              Close the submenu, select the current top-level menu item.
    var triggerButton = document.querySelector('[aria-controls="' + this.id + '"]')
    var buttons = Array.prototype.slice.call(this.querySelectorAll(':not([disabled])'))
    var index = buttons.indexOf(event.target)
    var expectedKey = false
    var newIndex

    // Handling use of space on keyup and all other keys on keydown
    // as I had problems with space on keydown.
    if ((event.type === 'keyup' && event.keyCode === keys.space) ||
      (event.type === 'keydown' && [keys.enter, keys.down, keys.up, keys.tab, keys.esc].indexOf(event.keyCode) !== -1)) {
      switch (event.keyCode) {
        case keys.tab:
          hide(this)
          if (event.shiftKey) {
            _u.focus.prev(triggerButton)
          } else {
            _u.focus.next(triggerButton)
          }
          expectedKey = true
          break
        case keys.down:
          newIndex = index !== buttons.length - 1 ? index + 1 : 0
          buttons[newIndex].focus()
          expectedKey = true
          break
        case keys.up:
          newIndex = index > 0 ? index - 1 : buttons.length - 1
          buttons[newIndex].focus()
          expectedKey = true
          break
        case keys.enter:
        case keys.space:
          // This means one of the buttons has been activated.
          // Anything could be happening in the DOM at this moment, with elements being removed or added.
          // We need to make sure that focus is returned to somewhere sensible.
          // But if the content, including the triggering button, has been updated, where can we go?
          // Maybe we can't know, so we should emit an event and the application can handle it.

          // Save the id.
          var originalId = this.id
          // Use a timeout so this is executed after the keydown handler has finished work.
          window.setTimeout(function () {
            // Re-querying the DOM for the trigger button.
            var originalTriggerButton = document.querySelector('[aria-controls="' + originalId + '"]')
            if (originalTriggerButton) {
              originalTriggerButton.focus()
            } else {
              _u.focus.lost(originalId)
            }
          }, 0)
          break
        case keys.esc:
          hide(this)
          triggerButton.focus()
          expectedKey = true
      }
    }
    if (expectedKey) {
      event.stopPropagation()
      event.preventDefault()
    }
  }

  /**
   * Preparation for toggling multiactions.
   * @param {string} id - the unique ID of the multiaction button
   * @return {Node} - the multiactions DOM node
   */
  function prepareToToggle (id) {
    var multiactions = document.getElementById(id)

    // Clear the timer used for the hide animation.
    if (multiactions.timer) {
      clearTimeout(multiactions.timer)
      multiactions.removeAttribute('data-hiding')
      multiactions.timer = 0
    }

    return multiactions
  }

  /**
   * Toggles multiactions.
   * @param {string} id - the unique ID of the multiaction button
   * @param {string} keyCode - an optional event keyCode
   */
  function toggle (id, keyCode) {
    var multiactions = prepareToToggle(id)
    if (multiactions === null) {
      return
    }

    keyCode = keyCode || null

    // Toggle.
    if (multiactions.getAttribute('aria-expanded') === 'false') {
      show(multiactions, keyCode)
    } else {
      hide(multiactions)
    }
  }

  /**
   * Displays multiactions.
   * @function show
   * @memberof! UCASDesignFramework.multiactionButtons
   * @public
   * @param {(Node|string)} multiactions - the multiactions DOM node or its id
   * @param {string} keyCode - an optional event keyCode
   */
  function show (multiactions, keyCode) {
    // Allow to be used with id as a parameter.
    if (!multiactions.nodeType) {
      multiactions = prepareToToggle(multiactions)
      if (multiactions === null) {
        return
      }
    }

    var trigger = document.querySelector('button[aria-controls="' + multiactions.id + '"]')

    // Hide any already open.
    hideAll()

    // Add the container if it doesn't exist.
    if (!multiactionButtonContainer) {
      multiactionButtonContainer = document.createElement('div')
      multiactionButtonContainer.id = 'multiactionButtonContainer'
      document.body.appendChild(multiactionButtonContainer)
      multiactionButtonContainer.addEventListener('click', function (e) {
        // Capture any clicks and hide all multiaction buttons and this container,
        // otherwise it may prevent interaction with other elements.
        hideAll()
      }, true)
    }

    // Set an attribute on the button (which may or may not have been used to trigger).
    trigger.setAttribute('data-multiaction-button-expanded', '')

    // Move to container.
    multiactionButtonContainer.appendChild(multiactions)

    // Display.
    multiactionButtonContainer.hidden = false
    multiactions.removeAttribute('data-hiding')
    multiactions.setAttribute('aria-expanded', 'true')

    // Position accordingly.
    setMultiactionButtonPosition(multiactions, trigger)

    // Focus when using the keyboard.
    if (keyCode) {
      var activeButtons = multiactions.querySelectorAll(':not([disabled])')
      if (activeButtons) {
        if (keyCode === keys.up) {
          activeButtons[activeButtons.length - 1].focus()
        } else {
          activeButtons[0].focus()
        }
      }
    }

    // Handle clicking elsewhere.
    document.addEventListener('click', hideAll, false)
    // Handle resize.
    window.addEventListener('dfResize', hideAll, false)
  }

  /**
   * Positions the multiactions relative to the trigger.
   * @function setMultiactionButtonPosition
   * @memberof! UCASDesignFramework.multiactionButtons
   * @public
   * @param {(Node)} multiactions - the multiactions DOM node
   * @param {(Node)} trigger - the trigger DOM node
   */
  function setMultiactionButtonPosition (multiactions, trigger) {
    _u.position.clone(multiactions, trigger, 0, trigger.clientHeight, trigger.clientWidth, 0)
  }

  /**
   * Hides all open multiactions.
   * @function hideAll
   * @memberof! UCASDesignFramework.multiactionButtons
   * @public
   */
  function hideAll () {
    var items = document.querySelectorAll('[data-multiaction-button-expanded]')
    _u.forEach(items, function (i, el) {
      var id = el.getAttribute('aria-controls')
      hide(id)
    })
  }

  /**
   * Hides multiactions.
   * @function hide
   * @memberof! UCASDesignFramework.multiactionButtons
   * @public
   * @param {(Node|string)} multiactions - the multiactions DOM node or its id
   */
  function hide (multiactions) {
    // Allow to be used with id as a parameter.
    if (!multiactions.nodeType) {
      multiactions = prepareToToggle(multiactions)
      if (multiactions === null) {
        return
      }
    }

    // Remove listener for clicks elsewhere.
    document.removeEventListener('click', hideAll, false)
    // Remove listener for resize.
    window.removeEventListener('dfResize', hideAll, false)

    var trigger = document.querySelector('button[aria-controls="' + multiactions.id + '"]')

    // Handle a situation where the button has been removed from the DOM by something else.
    if (!trigger) {
      _u.log.log('multiactionButtons: missing trigger', multiactions.id)
      cleanUp()
      return
    }

    // Hide with an animation.
    trigger.removeAttribute('data-multiaction-button-expanded')
    multiactions.setAttribute('data-hiding', '')
    multiactions.setAttribute('aria-expanded', 'false')
    multiactions.timer = window.setTimeout(function () {
      multiactions.removeAttribute('data-hiding')
    }, 800)

    if (multiactionButtonContainer) {
      multiactionButtonContainer.hidden = true
    }

    // Move back to original place.
    trigger.parentNode.insertBefore(multiactions, trigger.nextSibling)
  }

  /**
   * Disables multiactions.
   * @function disable
   * @memberof! UCASDesignFramework.multiactionButtons
   * @public
   * @param {(Node|string)} multiactions - the multiactions DOM node or its id
   */
  function disable (multiactions) {
    // Allow to be used with id as a parameter.
    if (!multiactions.nodeType) {
      multiactions = prepareToToggle(multiactions)
      if (multiactions === null) {
        return
      }
    }

    // Hide if already open.
    hide(multiactions)

    // Set disabled attribute on the button.
    multiactions.parentNode.querySelector('[aria-controls]').setAttribute('disabled', '')
  }

  /**
   * Enables multiactions.
   * @function enable
   * @memberof! UCASDesignFramework.multiactionButtons
   * @public
   * @param {(Node|string)} multiactions - the multiactions DOM node or its id
   */
  function enable (multiactions) {
    // Allow to be used with id as a parameter.
    if (!multiactions.nodeType) {
      multiactions = prepareToToggle(multiactions)
      if (multiactions === null) {
        return
      }
    }

    // Remove disabled attribute from the button.
    multiactions.parentNode.querySelector('[aria-controls]').removeAttribute('disabled')
  }

  // Expose public methods.
  global.multiactionButtons = {
    addSubscriptions: true,
    destroy: destroy,
    init: init,
    show: show,
    hide: hide,
    hideAll: hideAll,
    disable: disable,
    enable: enable
  }
})(UCASDesignFramework, UCASUtilities)