'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)