@borngroup/born-modal
Version:
BORN Module to handle Modals. Provides callbacks and static methods to open, update, and close the modals
540 lines (447 loc) • 22.2 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", {
value: true
});
exports["default"] = void 0;
var _bornUtilities = require("@borngroup/born-utilities");
var _bodyScrollLock = require("body-scroll-lock");
function _typeof(obj) { "@babel/helpers - typeof"; if (typeof Symbol === "function" && typeof Symbol.iterator === "symbol") { _typeof = function _typeof(obj) { return typeof obj; }; } else { _typeof = function _typeof(obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; }; } return _typeof(obj); }
function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }
var Modal = /*#__PURE__*/function () {
function Modal(options) {
_classCallCheck(this, Modal);
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
*/
_createClass(Modal, [{
key: "_renderModal",
value: function _renderModal() {
var _this = this;
this.modalEl = (0, _bornUtilities.createElWithAttrs)(false, {
'id': 'modal-' + this.options.modalID,
'class': this.options.modalClass,
'data-modal': true
});
this.modalEl.modal = {};
this.modalEl.modal.content = (0, _bornUtilities.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.
var checkReadyTries = 0,
checkReady = setInterval(function () {
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 = (0, _bornUtilities.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);
}
}, {
key: "getCustomAttributes",
/**
* 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]
*/
value: function getCustomAttributes(targetModal) {
var 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]
*/
}], [{
key: "setModalPosition",
value: function 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.
*/
}, {
key: "toggleModalScroll",
value: function toggleModalScroll(targetModal, disable) {
var scrollableEls = targetModal.querySelectorAll('[data-modal-scrollable]'),
toggleBodyScroll = disable ? _bodyScrollLock.disableBodyScroll : _bodyScrollLock.enableBodyScroll,
scrollOptions = disable ? {
allowTouchMove: function allowTouchMove(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);
}
}, {
key: "setModalShown",
value: function setModalShown() {
//Prevent modals from getting scroll-locked in case `disableBodyScroll()` had been called before.
(0, _bodyScrollLock.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((0, _bornUtilities.whichTransition)(), Modal.setModalShown);
}
/**
* Opens referenced modal
* @param {[HTMLElement || String]} targetModal [targetModal element or ID to be opened]
*/
}, {
key: "openModal",
value: function openModal(targetModal) {
var 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.
var 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);
}
}
}, {
key: "setupEventListeners",
value: function 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((0, _bornUtilities.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.
*/
}, {
key: "storeLastEvent",
value: function storeLastEvent(evt) {
this.modal.lastEvent = evt;
}
}, {
key: "updateAttributes",
value: function updateAttributes(targetModal, isActive) {
var customAttributes = targetModal.modal.options.customAttributes;
for (var 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.
*/
}, {
key: "setAttributeValue",
value: function setAttributeValue(el, attrName, attrObject, isActive) {
var 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]
*/
}, {
key: "focusModal",
value: function focusModal(targetModal) {
targetModal.modal.content.focus();
targetModal.modal.content.style.outline = 'none';
(0, _bornUtilities.focusTrap)(targetModal);
}
/**
* Replaces modal's ID and content with the provided values
*/
}, {
key: "updateModal",
value: function updateModal(targetModal, content, newID) {
targetModal = Modal.getModal(targetModal);
if (targetModal.modal.beforeOpenCallback(targetModal)) {
if (newID) {
targetModal.id = 'modal-' + newID;
}
if (content) {
var 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]
*/
}, {
key: "closeAllModals",
value: function closeAllModals() {
var activeModals = Modal.getActiveModals();
[].forEach.call(activeModals, function (currentModal) {
Modal.closeModal(false, true);
});
}
/**
* [closeModal method to... You guessed it, close modals!]
* @param {[object]} e [event]
*/
}, {
key: "closeModal",
value: function closeModal(evt, ignoreBeforeCallback) {
var 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.
var 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))) {
var 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((0, _bornUtilities.whichTransition)(), Modal.destroyModal);
}
targetModal.modal.afterCloseCallback(targetModal);
}
}
}, {
key: "destroyModal",
value: function destroyModal() {
var targetModal = this || Modal.getActiveModal();
targetModal.removeEventListener((0, _bornUtilities.whichTransition)(), Modal.destroyModal);
targetModal.parentNode.removeChild(targetModal);
} //Inserts close button into modal
}, {
key: "insertCloseBtn",
value: function insertCloseBtn(targetModal) {
var closeBtnContainer = targetModal.modal.options.crossCloseContainer === 'modal' ? targetModal : targetModal.modal.content;
return (0, _bornUtilities.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.
}, {
key: "insertContent",
value: function insertContent(targetModal, content) {
if (typeof content === 'string') {
targetModal.modal.content.insertAdjacentHTML('afterbegin', content);
} else if (content instanceof HTMLElement) {
targetModal.modal.content.appendChild(content);
}
(0, _bornUtilities.parseScripts)(targetModal);
}
/**
* Gets all the currently active modals.
* @return {NodeList}
*/
}, {
key: "getActiveModals",
value: function getActiveModals() {
return document.querySelectorAll('.window-modal[data-modal-active]');
}
/**
* Gets the active modal higher in the display.
* @return {NodeList}
*/
}, {
key: "getActiveModal",
value: function getActiveModal() {
var 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}
*/
}, {
key: "getModal",
value: function getModal(targetModal) {
var 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;
}
}
}]);
return Modal;
}();
exports["default"] = Modal;