UNPKG

@borngroup/born-modal

Version:

BORN Module to handle Modals. Provides callbacks and static methods to open, update, and close the modals

486 lines (392 loc) 21 kB
import {createElWithAttrs, whichTransition, objectAssign, focusTrap, parseScripts} from '@borngroup/born-utilities'; import {disableBodyScroll, enableBodyScroll, clearAllBodyScrollLocks} from 'body-scroll-lock'; export default class Modal { constructor(options){ this.options = options || {}; //Options this.options.modalID = this.options.modalID || 'auto-' + Math.floor(new Date().getTime() * Math.random()).toString(); this.options.modalClass = 'window-modal ' + (this.options.modalClass || ''); this.options.openImmediately = this.options.hasOwnProperty('openImmediately') ? this.options.openImmediately : true; this.options.allowEscClose = this.options.hasOwnProperty('allowEscClose') ? this.options.allowEscClose : true; this.options.allowClickOutClose = this.options.hasOwnProperty('allowClickOutClose') ? this.options.allowClickOutClose : true; this.options.allowCrossClose = this.options.hasOwnProperty('allowCrossClose') ? this.options.allowCrossClose : true; this.options.resetScrollPositionOnClose = this.options.hasOwnProperty('resetScrollPositionOnClose') ? this.options.resetScrollPositionOnClose : true; this._modalContentClass = 'window-modal__content'; if (typeof this.options.container === 'string') { this.options.container = document.querySelector(this.options.container); } else if (this.options.container instanceof HTMLElement) { this.options.container = this.options.container; } else { this.options.container = document.querySelector('body'); } //If modal doesn't exist, create it. if (!Modal.getModal(this.options.modalID)) { this._renderModal(); } else { //Otherwise, just open it. Modal.openModal(this.options.modalID); } } /** * Creates the modal */ _renderModal() { this.modalEl = createElWithAttrs(false, {'id': 'modal-' + this.options.modalID, 'class': this.options.modalClass, 'data-modal': true}); this.modalEl.modal = {}; this.modalEl.modal.content = createElWithAttrs(this.modalEl, {'class': this._modalContentClass, 'tabindex': '-1'}); this.modalEl.modal.options = this.options; this.modalEl.modal.container = this.options.container; this.modalEl.modal.keepAlive = this.options.hasOwnProperty('keepAlive') ? this.options.keepAlive : true; //Callbacks this.modalEl.modal.beforeOpenCallback = this.options.beforeOpenCallback || function() { return true; }; this.modalEl.modal.afterOpenCallback = this.options.afterOpenCallback || function() {}; this.modalEl.modal.beforeCloseCallback = this.options.beforeCloseCallback || function() { return true; }; this.modalEl.modal.afterCloseCallback = this.options.afterCloseCallback || function() {}; this.modalEl.modal.afterCreateCallback = this.options.afterCreateCallback || function() {}; this.modalEl.modal.afterScrollLockCallback = this.options.afterScrollLockCallback || function() {}; //Methods this.open = this.modalEl.modal.open = Modal.openModal.bind(Modal, this.modalEl); this.close = this.modalEl.modal.close = Modal.closeModal; this.update = this.modalEl.modal.update = Modal.updateModal.bind(Modal, this.modalEl); if (this.options.content) { Modal.insertContent(this.modalEl, this.options.content); } this.options.container.appendChild(this.modalEl); //Checks to see if modal has been succesfully inserted in DOM before attempting to open it. let checkReadyTries = 0, checkReady = setInterval(() => { checkReadyTries++; if (Modal.getModal(this.options.modalID)) { clearInterval(checkReady); if (this.options.allowCrossClose) { Modal.insertCloseBtn(this.modalEl); } if (this.options.openImmediately) { Modal.openModal(this.modalEl); } this.modalEl.modal.options.customAttributes = objectAssign(this.getCustomAttributes(this.modalEl), this.modalEl.modal.options.customAttributes); Modal.updateAttributes(this.modalEl); this.modalEl.modal.afterCreateCallback(this.modalEl); } else if (checkReadyTries >= 25) { clearInterval(checkReady); } }, 10); } static setModalPosition() { Modal.positionTop = Math.abs(document.body.getBoundingClientRect().top); } /** * @param {HTMLElement} targetModal [description] * @param {Boolean} disable When `disable` is TRUE, the body scrolling will be disabled. * Leave empty or set to FALSE to allow body scrolling. */ static toggleModalScroll(targetModal, disable) { let scrollableEls = targetModal.querySelectorAll('[data-modal-scrollable]'), toggleBodyScroll = disable ? disableBodyScroll : enableBodyScroll, scrollOptions = disable ? { allowTouchMove: function(el) { while (el && el !== document.body) { if (el.hasAttribute('data-modal-scrollable')) { return true; } el = el.parentNode; } } } : {}; toggleBodyScroll(targetModal, scrollOptions); //This is a hacky way to force a browser repaint because for some reason they need this. targetModal.scrollHeight; targetModal.modal.afterScrollLockCallback(targetModal); } static setModalShown() { //Prevent modals from getting scroll-locked in case `disableBodyScroll()` had been called before. clearAllBodyScrollLocks(); //Only add these classes/states if the modal is active. //This prevents locking the viewport when user promptly closes modal before it's done animating. if (this.hasAttribute('data-modal-active')) { if (!this.modal.options.allowScrolling) { if (this.modal.options.lockViewport) { document.documentElement.classList.add('cancel-scroll'); } else { Modal.toggleModalScroll(this, true); } } this.modal.container.classList.add('modal-shown'); } Modal.focusModal(this); this.removeEventListener(whichTransition(), Modal.setModalShown); } /** * Opens referenced modal * @param {[HTMLElement || String]} targetModal [targetModal element or ID to be opened] */ static openModal(targetModal) { let activeModal = Modal.getActiveModal(); targetModal = Modal.getModal(targetModal); //Do not process `openModal` any further if the targetModal is already open. if (targetModal === activeModal) { return false; } else if (targetModal.modal.beforeOpenCallback(targetModal)) { //Add modal index every time a modal is opened. This can be used to determine the priority order of the modals. let targetModalIndex = activeModal ? parseInt(activeModal.getAttribute('data-modal-index')) + 1 : 0; targetModal.setAttribute('data-modal-index', targetModalIndex); Modal.setModalPosition(); if (!targetModal.modal.options.overlayOthers) { Modal.closeAllModals(); } else if (activeModal) { targetModal.modal.modalInBackground = activeModal; activeModal.classList.add('modal-in-background'); } targetModal.classList.add('modal-active'); targetModal.setAttribute('data-modal-active', true); Modal.setupEventListeners(targetModal); //If option is specified, closes the modal after `timeOut`. if (targetModal.modal.options.timeOut) { window.setTimeout(Modal.closeModal, targetModal.modal.options.timeOut); } //Run this when eerything's in place targetModal.modal.afterOpenCallback(targetModal); } } static setupEventListeners(targetModal) { targetModal.addEventListener('mousedown', Modal.storeLastEvent); targetModal.addEventListener('click', Modal.closeModal); targetModal.addEventListener('mouseup', Modal.closeModal); if (targetModal.modal.options.allowEscClose) { document.body.addEventListener('keydown', Modal.closeModal); } targetModal.addEventListener(whichTransition(), Modal.setModalShown); } /** * Store the last "mousedown" event data so that it can later be retrieved and compared with the "mouseup" event data. * This prevents closing the modal too early when using a "mousedown" listener only. */ static storeLastEvent(evt) { this.modal.lastEvent = evt; } /** * Setup custom HTML attributes for the modal. * Default to setting a few aria-attributes to give more context to the browser. * @param {[type]} trigger [description] * @return {[type]} [description] */ getCustomAttributes(targetModal) { let labelledByEl = targetModal.querySelector('[data-modal-component="labelledby"]'), describedByEl = targetModal.querySelector('[data-modal-component="describedby"]'); //`value`: [String | Array] If Array, index 0 is used when Toggle is unset, and index 1 is used when it's set. //`trigger`: [Boolean] Set to true to only attach the attribute to the trigger element. //`target`: [Boolean] Set to true to only attach the attribute to the target element. if (labelledByEl && !labelledByEl.id){ labelledByEl.id ='ID_' + Math.floor(new Date().getTime() * Math.random()).toString(); } if (describedByEl && !describedByEl.id){ describedByEl.id = 'ID_' + Math.floor(new Date().getTime() * Math.random()).toString(); } return { 'role': { value: 'dialog', target: true }, 'aria-labelledby': labelledByEl ? {value: labelledByEl.id, target: true} : false, 'aria-describedby': describedByEl ? {value: describedByEl.id, target: true} : false, 'aria-modal': { value: 'true', target: true } }; } /** * Loop through the `targetModal.modal.options.customAttributes` object and update the configured attributes. * This method is also called whenever the Modal is shown or hidden, in case the attributes should change. * @param {[type]} modal [description] * @param {Boolean} isActive [description] * @return {[type]} [description] */ static updateAttributes(targetModal, isActive) { let customAttributes = targetModal.modal.options.customAttributes; for (let attrKey in customAttributes) { if (customAttributes[attrKey]) { if (customAttributes[attrKey].trigger) { // Modal.setAttributeValue(trigger, attrKey, customAttributes[attrKey], isActive); } else if (customAttributes[attrKey].target) { Modal.setAttributeValue(targetModal.modal.content, attrKey, customAttributes[attrKey], isActive); } else { // Modal.setAttributeValue(trigger, attrKey, customAttributes[attrKey], isActive); Modal.setAttributeValue(targetModal.modal.content, attrKey, customAttributes[attrKey], isActive); } } } } /** * Updates a single Toggle element with the custom attributes provided in `attrName` and `attrObject` * Set the `isActive` argument to TRUE to swap the attribute value when `attrObject.value` is an Array. */ static setAttributeValue(el, attrName, attrObject, isActive) { let value = typeof attrObject.value === 'string' ? attrObject.value : (isActive ? attrObject.value[1] : attrObject.value[0]); el.setAttribute(attrName, value); } /** * Sets up a focus trap when a modal is open. * @param {[type]} targetModal [description] */ static focusModal(targetModal) { targetModal.modal.content.focus(); targetModal.modal.content.style.outline = 'none'; focusTrap(targetModal); } /** * Replaces modal's ID and content with the provided values */ static updateModal(targetModal, content, newID) { targetModal = Modal.getModal(targetModal); if (targetModal.modal.beforeOpenCallback(targetModal)) { if (newID) { targetModal.id = 'modal-' + newID; } if (content) { let targetModalContent = targetModal.querySelector('.window-modal__content'); targetModalContent.innerHTML = ''; Modal.insertContent(targetModal, content); if (targetModal.modal.options.allowCrossClose) { Modal.insertCloseBtn(targetModal); } } //Run this when everything's in place targetModal.modal.afterCreateCallback(targetModal); targetModal.modal.afterOpenCallback(targetModal); } } /** * Loops through active modals and closes them all. * @return {[type]} [description] */ static closeAllModals() { let activeModals = Modal.getActiveModals(); [].forEach.call(activeModals, function(currentModal) { Modal.closeModal(false, true); }); } /** * [closeModal method to... You guessed it, close modals!] * @param {[object]} e [event] */ static closeModal(evt, ignoreBeforeCallback) { let targetModal = Modal.getActiveModal(), canClose = true, isCloseAllTarget, isCloseTarget; if (!targetModal) { return; } if (typeof evt === 'object') { //1000% sure make sure the user intended to specifically click on the overlay. //A "lastEvent" property is stored when "mousedown" happens, which we then use here to compare with the new event target. let isOverlayTarget = targetModal.modal.options.allowClickOutClose && evt.type === 'mouseup' && evt.target === targetModal && targetModal.modal.lastEvent.target === targetModal && evt.button === 0, evtIsClick = evt.type === 'click', evtIsEscKey = document.activeElement.tagName !== 'INPUT' && evt.keyCode === 27 && targetModal.modal.options.allowEscClose, isCloseTarget = evtIsClick && evt.target.closest('[data-modal-close]'); isCloseAllTarget = evtIsClick && evt.target.closest('[data-modal-close-all]'); canClose = isOverlayTarget || isCloseTarget || isCloseAllTarget || evtIsEscKey; } //Check beforeCloseCallback before attempting to close the modal. //If ignoreBeforeCallback is provided, ignore beforeCloseCallback. if (canClose && (ignoreBeforeCallback || targetModal.modal.beforeCloseCallback(targetModal))) { let activeModals = Modal.getActiveModals(); //Only remove listeners and class if there is 1 modal or less left. if (activeModals.length <= 1) { document.body.removeEventListener('keydown', Modal.closeModal); document.documentElement.classList.remove('cancel-scroll'); if (targetModal.modal.options.resetScrollPositionOnClose) { window.scrollTo(0, Modal.positionTop || 0); } } else if (targetModal.modal.options.closeAll || isCloseAllTarget) { //If user clicked on an element with `data-modal-close-all`, //or if the modal being closed has the `closeAll` option, close all remaining modals. Modal.closeAllModals(); } targetModal.classList.remove('modal-active'); targetModal.removeAttribute('data-modal-active'); //Remove scroll-locking from the current modal. //It will be re-set in the backgrounded modals, if any. if (!targetModal.modal.options.lockViewport) { Modal.toggleModalScroll(targetModal); } //Optionally set the focus back to a specified `afterCloseFocusEl` element. //However only focus on it if at the time of closing the modal, the user was focusing an element within the modal. //This is necessary to prevent re-assigning focus when it was already intentionally shifted somewhere else. //i.e. a user hits "add to cart" which closes the modal and somewhere else in the code the focus is assigned to a minicart. if (targetModal.modal.options.afterCloseFocusEl && targetModal.contains(document.activeElement)) { targetModal.modal.options.afterCloseFocusEl.focus(); } //Only remove the container's modal-shown class if the current modal has no modal in background, //or if the current modal's container is different than the background modal's. if (!targetModal.modal.modalInBackground || targetModal.modal.modalInBackground.modal.container !== targetModal.modal.container) { targetModal.modal.container.classList.remove('modal-shown'); } //Remove the modal-in-background class from the backgrounded modal if it exists. if (targetModal.modal.modalInBackground) { //Re-set the scroll locking on backgrounded modals if they did not have the `lockViewport` option. if (!targetModal.modal.modalInBackground.modal.options.lockViewport && !targetModal.modal.modalInBackground.modal.options.allowScrolling) { Modal.toggleModalScroll(targetModal.modal.modalInBackground, true); } targetModal.modal.modalInBackground.classList.remove('modal-in-background'); } if (!targetModal.modal.keepAlive) { targetModal.addEventListener(whichTransition(), Modal.destroyModal); } targetModal.modal.afterCloseCallback(targetModal); } } static destroyModal() { let targetModal = this || Modal.getActiveModal(); targetModal.removeEventListener(whichTransition(), Modal.destroyModal); targetModal.parentNode.removeChild(targetModal); } //Inserts close button into modal static insertCloseBtn(targetModal) { let closeBtnContainer = targetModal.modal.options.crossCloseContainer === 'modal' ? targetModal : targetModal.modal.content; return createElWithAttrs(closeBtnContainer, {'class': 'window-modal__close', 'data-modal-close': true, 'title': 'Close modal', 'aria-label': 'Close modal', 'type': 'button'}, 'button'); } //Adds modal content depending as a string or as a node. static insertContent(targetModal, content) { if (typeof content === 'string') { targetModal.modal.content.insertAdjacentHTML('afterbegin', content); } else if (content instanceof HTMLElement) { targetModal.modal.content.appendChild(content); } parseScripts(targetModal); } /** * Gets all the currently active modals. * @return {NodeList} */ static getActiveModals() { return document.querySelectorAll('.window-modal[data-modal-active]'); } /** * Gets the active modal higher in the display. * @return {NodeList} */ static getActiveModal() { let activeModals = Modal.getActiveModals(); //THIS SHOULD BE CHANGED TO GET THE HIGHEST INDEX FROM CURRENTLY VISIBLE MODALS. return activeModals[activeModals.length - 1]; } /** * Returns Modal NodeElement if the passed ID matches a modal. * @return {HTMLElement} */ static getModal(targetModal) { let matchedModal = typeof targetModal === 'string' ? (document.querySelector('#modal-' + targetModal) || document.querySelector(targetModal)) : false; if (matchedModal) { return matchedModal; } else if (targetModal instanceof HTMLElement) { //Return itself if the 'targetModal' is an HTML element. //Intentionally empty return targetModal; } else { //targetModal is not a string nor an HTMLElement, return false. return false; } } }