Source: components/widgets/modal/_modal-v5.js

'use strict';

/**
 * Modal component.
 * @namespace modal
 * @memberof UCASDesignFramework
 * @param {object} global - UCASDesignFramework object.
 * @param {object} _u - UCASUtilities object.
 * @see {@link https://w3c.github.io/aria-practices/examples/dialog-modal/dialog.html}
 */
(function (global, _u) {
  var focusTracker
  var activeModals = [] // Used to keep track of how many modals are open.
  var iOS = !!navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform)
  // We need to hide the main content from screenreaders when displaying the modals.
  // All of the non-modal content should be contained within this wrapper.
  var mainContent = document.getElementById('wrapper')

  /**
   * Initialise plugin
   * @function init
   * @memberof! UCASDesignFramework.modal
   * @param {Node} context - a DOM element to limit selection to.
   * @public
  */
  function init (context) {
    // We're being temporarily safe here and checking we're using v5
    // before overriding the existing namespace.
    // @todo Remove the check in v5!
    if (document.body.classList.contains('v5') || document.body.classList.contains('v5-modal')) {
      global.modal = {
        init: init,
        destroy: destroy,
        showModal: showModal,
        hideModal: hideModal,
        Modal: Modal,
        get active () {
          return activeModals
        }
      }

      delete global.v5Modal
    } else {
      return
    }

    global.subscriptions.addSubscriptions(global.modal, 'modal')

    context = context || document
    var modalTriggers = context.querySelectorAll('[data-modal-trigger]')

    modalTriggers.forEach(function (el) {
      el.addEventListener('click', showModalClickHandler, false)
    })

    // Handle form item modals.
    var formItemModals = context.querySelectorAll('[data-form-item-modal]')
    if (formItemModals) { initFormItemModals(formItemModals) }

    // Listen for the hide event.
    document.addEventListener('modalHide', modalHideEventListener, false)
  }

  /**
   * Check for open modals.
   * @param {Event} e - the DOM event
   */
  function modalHideEventListener (e) {
    var open = document.querySelectorAll('[data-modal-state="visible"]')
    if (open.length === 0) {
      _u.log.log('MODALS hideModal event cleanup', e)
      activeModals = []
      document.documentElement.removeAttribute('data-modal-active')
      if (mainContent) {
        mainContent.removeAttribute('aria-hidden')
      }
    }
  }

  /**
   * Hide the modal with the Escape key.
   * @param {Event} e - the DOM event
   */
  function keydownEventHandler (e) {
    e = e || window.event
    var modal = document.querySelector('[data-modal-state="visible"] .modal')

    if (e.keyCode === 27 && modal) {
      hideModal()
    }

    if (e.keyCode === 9 && modal) {
      // Trap the focus within the modal when using the keyboard.
      var tabbable = _u.focus.listTabbableElements(modal)
      var focusable = _u.focus.listFocusableElements(modal)
      if (e.shiftKey && (e.target === tabbable[0] || e.target === focusable[0])) {
        tabbable[tabbable.length - 1].focus()
        e.preventDefault()
      } else if (!e.shiftKey && e.target === tabbable[tabbable.length - 1]) {
        tabbable[0].focus()
        e.preventDefault()
      }
    }
  }

  /**
   * Initialise form item modals.
   * @function initFormItemModals
   * @memberof! UCASDesignFramework.modal
   * @param {NodeList} formItemModals - the DOM elements. These should be .v5-form-items and contain a button.
   * @private
   */
  function initFormItemModals (formItemModals) {
    formItemModals.forEach(function (item) {
      var id = item.getAttribute('data-form-item-modal')
      var button = item.querySelector('button')
      if (document.getElementById(id) && button) {
        var el = button.parentNode.firstElementChild
        do { if (button !== el) { el.style.display = 'none' } } while ((el = el.nextElementSibling))
        button.setAttribute('data-modal-id', id)
        button.setAttribute('data-modal-trigger', '')
        var label = button.getAttribute('data-form-item-modal-label')
        if (label) { button.innerText = label }
        button.addEventListener('click', showModalClickHandler, false)
      }
    })
  }

  /**
   * Remove event listeners and close all open modals.
   * @function destroy
   * @memberof! UCASDesignFramework.modal
   * @public
   */
  function destroy () {
    var modalTriggers = document.querySelectorAll('[data-modal-trigger]')
    _u.forEach(modalTriggers, function (i, el) {
      el.removeEventListener('click', showModal)
    })
    var targets = document.querySelectorAll('[data-modal-close]')
    _u.forEach(targets, function (i, el) {
      el.removeEventListener('click', hideModal)
    })
    var openModal = document.querySelector('[data-modal-state="visible"]')
    if (openModal) {
      openModal.setAttribute('data-modal-state', 'hidden')
    }
    activeModals = []

    document.documentElement.removeAttribute('data-modal-active')
    if (mainContent) {
      mainContent.removeAttribute('aria-hidden')
    }
  }

  /**
   * Works out which modal is at the top.
   * @returns {Node} - the topmost modal
   */
  function topModal () {
    return document.getElementById(activeModals[activeModals.length - 1])
  }

  /**
   * Click handler to display a modal.
   * @function showModalClickHandler
   * @memberof! UCASDesignFramework.modal
   * @param {Event} e - click event, the target of which contains a data-modal-id attribute.
   * @private
   */
  function showModalClickHandler (e) {
    e.preventDefault()
    showModal(e, e.target)
  }

  /**
   * Displays a modal.
   * @function showModal
   * @memberof! UCASDesignFramework.modal
   * @param {string|event} modalID - ID of modal to show or click event, the target of which contains a data-modal-id attribute.
   * @param {Node} returnFocusElement - the element to return focus to when closing the modal.
   */
  function showModal (modalID, returnFocusElement) {
    if (typeof modalID === 'object') {
      modalID = modalID.target.attributes['data-modal-id'].value
    } else if (typeof modalID !== 'string') {
      _u.log.error('Can\'t open modal!', modalID, 'should be a string or event!')
      return
    }

    focusTracker = modalID

    var targetModal = document.getElementById(modalID)
    if (!targetModal) {
      _u.log.error('Modal with ID ' + modalID + ' does not exist.')
      return
    }

    if (targetModal.getAttribute('data-modal-state') === 'visible') {
      _u.log.warn('Modal with ID ' + modalID + ' is already open.')
      return
    }

    var modal = targetModal.querySelector('.modal')
    if (!modal) {
      _u.log.error('Modal with ID ' + modalID + ' does not contain a div.modal.')
      return
    }

    targetModal.setAttribute('data-modal-state', 'visible')
    modal.setAttribute('aria-modal', 'true')

    // See if the first sensible element in the modal is focusable.
    // If it isn't, then make it focusable and focus on it.
    var firstElement = modal.querySelector('[data-modal-focus]') || _u.focus.listFocusableElements(modal.querySelector('.modal__content'))[0] || modal.querySelector('.modal__content > :first-child')
    if (firstElement) {
      if (!_u.focus.isFocusable(firstElement)) {
        firstElement.setAttribute('tabindex', '-1')
      }
      firstElement.focus()
    } else {
      console.warn('MODALS This modal needs an element that will receive focus when the modal is activated.', modal)
      modal.setAttribute('tabindex', '-1')
      modal.focus()
    }

    // Store the return element on the modal element.
    targetModal.returnFocusElement = returnFocusElement || null
    // Escape key should close modal for accessibility
    document.addEventListener('keydown', keydownEventHandler, false)
    // Trap the focus
    document.addEventListener('focus', focusEventHandler, true)

    var content = targetModal.querySelector('.modal__content')
    if (content) {
      content.scrollTop = 0 // Always start from the top. scrollTo() doesn't work in Edge.
    }
    attachCloseListener(targetModal)
    // Add the modalID to the array of activeModals but check it's not already
    // listed there first, as we want to avoid timing issues.
    if (activeModals.indexOf(modalID) === -1) {
      activeModals.push(modalID)
    }
    // Set a nice, healthy z-index
    targetModal.style.zIndex = 1000 + activeModals.length

    _u.log.log('MODALS activeModals++', activeModals)
    document.documentElement.setAttribute('data-modal-active', '')
    if (mainContent) {
      // Hide the main content from screenreaders.
      // This is necessary for VoiceOver, which doesn't yet do the right thing with role="dialog".
      // Test by interacting with the screenreader.
      // e.g. In NVDA you can use Insert + F7 to bring up the elements list
      // or in VoiceOver use ctrl + opt + u to bring up the rotor.
      // With the modal open, only the modal elements should be listed.
      mainContent.setAttribute('aria-hidden', 'true')
    }

    // Handling iOS issues.
    if (iOS) {
      global.size.lockPosition()
    }

    /**
     * @event modalShow
     * @memberof! UCASDesignFramework.modal
     */
    var event = document.createEvent('Event')
    event.initEvent('modalShow', true, true)
    event.modalId = modalID
    targetModal.dispatchEvent(event)
  }

  /**
   * Attaches event listeners to close modal
   * @param {Node} targetModal - the modal
   */
  function attachCloseListener (targetModal) {
    targetModal.addEventListener('click', hideModalListener, false)

    var targets = targetModal.querySelectorAll('[data-modal-close]')
    _u.forEach(targets, function (i, el) {
      el.addEventListener('click', hideModalListener, false)
    })
  }

  /**
   * Hide modal listener.
   * @param {Event} e - the DOM event
   */
  function hideModalListener (e) {
    if (e.target.classList.contains('modal-container') || e.target.hasAttribute('data-modal-close')) {
      e.target.removeEventListener('click', hideModalListener, false)
      e.preventDefault()
      e.stopPropagation()
      hideModal()
    }
  }

  /**
   * Hides all modals or specific modalID
   * @function hideModal
   * @memberof! UCASDesignFramework.modal
   * @param {string} modalID] ID of modal to hide (optional)
   */
  function hideModal (modalID) {
    modalID = modalID || ''
    var targetModal

    document.removeEventListener('keydown', keydownEventHandler, false)
    document.removeEventListener('focus', focusEventHandler, true)

    if (modalID !== '') {
      targetModal = document.getElementById(modalID)
    } else {
      // As there is not a specific target, apply only to the top modal.
      targetModal = topModal()
      if (!targetModal) {
        _u.log.error('MODALS topModal() could not identify an open modal')
        return
      }
      _u.log.log('MODALS targetModal', targetModal)
      // We need a modal ID to keep track of things.
      modalID = targetModal.id
      // Handle the situation where the modals aren't properly registered.
      // This is only a temporary fix, this whole thing is going to be rewritten. See DF-1299.
      if (!focusTracker) {
        _u.log.error('UCASDesignFramework.modal.hideModal has been called but no modals are registered.')
        return
      }
      if (targetModal !== undefined) {
        if (typeof focusTracker !== 'string') {
          focusTracker.target.focus()
        }
      }
    }

    if (targetModal !== undefined) {
      targetModal.setAttribute('data-modal-closing', '')
      // Reset attributes once animations have run.

      // Return focus to trigger.
      if (targetModal.returnFocusElement) {
        targetModal.returnFocusElement.focus()
      } else {
        _u.focus.lost(modalID)
      }

      setTimeout(function () {
        targetModal.removeAttribute('data-modal-closing')
        targetModal.setAttribute('data-modal-state', 'hidden')
        /**
         * @event modalHide
         * @memberof! UCASDesignFramework.modal
         */
        var event = document.createEvent('Event')
        event.initEvent('modalHide', true, true)
        event.modalId = modalID
        targetModal.dispatchEvent(event)
      }, 800)
    }

    activeModals = activeModals.filter(function (e) { return e !== modalID })
    _u.log.log('MODALS activeModals--', activeModals)
    if (activeModals.length === 0) {
      document.documentElement.removeAttribute('data-modal-active')
      // Handling iOS issues.
      if (iOS) {
        global.size.unlockPosition()
        // Here follows a horrendous forced redraw to get Safari 13 (and below?) to behave.
        if (mainContent) {
          mainContent.style.webkitTransform = 'scale(1)'
          window.setTimeout(function () {
            mainContent.style.webkitTransform = ''
          }, 0)
        }
      }
    }
  }

  /**
   * Traps focus inside an active modal for accessibility.
   * @function focusEventHandler
   * @memberof! UCASDesignFramework.modal
   * @param {Event} e - click event.
   * @private
   */
  function focusEventHandler (e) {
    var activeModal = topModal()
    if ((activeModal && !activeModal.contains(e.target))) {
      e.stopPropagation()
      activeModal.querySelector('.modal').focus()
    }
  }

  /**
   * Provide a simple way to reference a modal and show/hide it.
   * @class
   * @memberof! UCASDesignFramework.modal
   * @public
   * @example
   * myLovelyModal = new UCASDesignFramework.modal.Modal("modal-1")
   * myLovelyModal.show()
   * myLovelyModal.hide()
   */
  var Modal = (function () {
    /**
     * @param {string} id - id of the modal
     */
    function Modal (id) {
      this.id = id
    }
    Modal.prototype.show = function () {
      showModal(this.id)
    }
    Modal.prototype.hide = function () {
      hideModal(this.id)
    }
    return Modal
  })()

  // Expose public methods.
  // Using a temporary namespace so this module gets noticed by the initialisation script
  // but we don't accidentally override the modal namespace if we're not meant to.
  // @todo Remove this temporary namespace in v5!
  global.v5Modal = {
    addSubscriptions: true,
    init: init
  }
})(UCASDesignFramework, UCASUtilities)