/** * Private ModalManager helper * Handles controlling modal stacking zIndexes and body adjustments/classes */ import { extend } from '../../../vue' import { IS_BROWSER } from '../../../constants/env' import { addClass, getAttr, getBCR, getCS, getStyle, hasAttr, removeAttr, removeClass, requestAF, selectAll, setAttr, setStyle } from '../../../utils/dom' import { isNull } from '../../../utils/inspect' import { toFloat, toInteger } from '../../../utils/number' // --- Constants --- // Default modal backdrop z-index const DEFAULT_ZINDEX = 1040 // Selectors for padding/margin adjustments const SELECTOR_FIXED_CONTENT = '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top' const SELECTOR_STICKY_CONTENT = '.sticky-top' const SELECTOR_NAVBAR_TOGGLER = '.navbar-toggler' // --- Main component --- // @vue/component const ModalManager = /*#__PURE__*/ extend({ data() { return { modals: [], baseZIndex: null, scrollbarWidth: null, isBodyOverflowing: false } }, computed: { modalCount() { return this.modals.length }, modalsAreOpen() { return this.modalCount > 0 } }, watch: { modalCount(newCount, oldCount) { if (IS_BROWSER) { this.getScrollbarWidth() if (newCount > 0 && oldCount === 0) { // Transitioning to modal(s) open this.checkScrollbar() this.setScrollbar() addClass(document.body, 'modal-open') } else if (newCount === 0 && oldCount > 0) { // Transitioning to modal(s) closed this.resetScrollbar() removeClass(document.body, 'modal-open') } setAttr(document.body, 'data-modal-open-count', String(newCount)) } }, modals(newValue) { this.checkScrollbar() requestAF(() => { this.updateModals(newValue || []) }) } }, methods: { // Public methods registerModal(modal) { // Register the modal if not already registered if (modal && this.modals.indexOf(modal) === -1) { this.modals.push(modal) } }, unregisterModal(modal) { const index = this.modals.indexOf(modal) if (index > -1) { // Remove modal from modals array this.modals.splice(index, 1) // Reset the modal's data if (!modal._isBeingDestroyed && !modal._isDestroyed) { this.resetModal(modal) } } }, getBaseZIndex() { if (IS_BROWSER && isNull(this.baseZIndex)) { // Create a temporary `div.modal-backdrop` to get computed z-index const div = document.createElement('div') addClass(div, 'modal-backdrop') addClass(div, 'd-none') setStyle(div, 'display', 'none') document.body.appendChild(div) this.baseZIndex = toInteger(getCS(div).zIndex, DEFAULT_ZINDEX) document.body.removeChild(div) } return this.baseZIndex || DEFAULT_ZINDEX }, getScrollbarWidth() { if (IS_BROWSER && isNull(this.scrollbarWidth)) { // Create a temporary `div.measure-scrollbar` to get computed z-index const div = document.createElement('div') addClass(div, 'modal-scrollbar-measure') document.body.appendChild(div) this.scrollbarWidth = getBCR(div).width - div.clientWidth document.body.removeChild(div) } return this.scrollbarWidth || 0 }, // Private methods updateModals(modals) { const baseZIndex = this.getBaseZIndex() const scrollbarWidth = this.getScrollbarWidth() modals.forEach((modal, index) => { // We update data values on each modal modal.zIndex = baseZIndex + index modal.scrollbarWidth = scrollbarWidth modal.isTop = index === this.modals.length - 1 modal.isBodyOverflowing = this.isBodyOverflowing }) }, resetModal(modal) { if (modal) { modal.zIndex = this.getBaseZIndex() modal.isTop = true modal.isBodyOverflowing = false } }, checkScrollbar() { // Determine if the body element is overflowing const { left, right } = getBCR(document.body) this.isBodyOverflowing = left + right < window.innerWidth }, setScrollbar() { const body = document.body // Storage place to cache changes to margins and padding // Note: This assumes the following element types are not added to the // document after the modal has opened. body._paddingChangedForModal = body._paddingChangedForModal || [] body._marginChangedForModal = body._marginChangedForModal || [] if (this.isBodyOverflowing) { const scrollbarWidth = this.scrollbarWidth // Adjust fixed content padding /* istanbul ignore next: difficult to test in JSDOM */ selectAll(SELECTOR_FIXED_CONTENT).forEach(el => { const actualPadding = getStyle(el, 'paddingRight') || '' setAttr(el, 'data-padding-right', actualPadding) setStyle(el, 'paddingRight', `${toFloat(getCS(el).paddingRight, 0) + scrollbarWidth}px`) body._paddingChangedForModal.push(el) }) // Adjust sticky content margin /* istanbul ignore next: difficult to test in JSDOM */ selectAll(SELECTOR_STICKY_CONTENT).forEach(el => /* istanbul ignore next */ { const actualMargin = getStyle(el, 'marginRight') || '' setAttr(el, 'data-margin-right', actualMargin) setStyle(el, 'marginRight', `${toFloat(getCS(el).marginRight, 0) - scrollbarWidth}px`) body._marginChangedForModal.push(el) }) // Adjust margin /* istanbul ignore next: difficult to test in JSDOM */ selectAll(SELECTOR_NAVBAR_TOGGLER).forEach(el => /* istanbul ignore next */ { const actualMargin = getStyle(el, 'marginRight') || '' setAttr(el, 'data-margin-right', actualMargin) setStyle(el, 'marginRight', `${toFloat(getCS(el).marginRight, 0) + scrollbarWidth}px`) body._marginChangedForModal.push(el) }) // Adjust body padding const actualPadding = getStyle(body, 'paddingRight') || '' setAttr(body, 'data-padding-right', actualPadding) setStyle(body, 'paddingRight', `${toFloat(getCS(body).paddingRight, 0) + scrollbarWidth}px`) } }, resetScrollbar() { const body = document.body if (body._paddingChangedForModal) { // Restore fixed content padding body._paddingChangedForModal.forEach(el => { /* istanbul ignore next: difficult to test in JSDOM */ if (hasAttr(el, 'data-padding-right')) { setStyle(el, 'paddingRight', getAttr(el, 'data-padding-right') || '') removeAttr(el, 'data-padding-right') } }) } if (body._marginChangedForModal) { // Restore sticky content and navbar-toggler margin body._marginChangedForModal.forEach(el => { /* istanbul ignore next: difficult to test in JSDOM */ if (hasAttr(el, 'data-margin-right')) { setStyle(el, 'marginRight', getAttr(el, 'data-margin-right') || '') removeAttr(el, 'data-margin-right') } }) } body._paddingChangedForModal = null body._marginChangedForModal = null // Restore body padding if (hasAttr(body, 'data-padding-right')) { setStyle(body, 'paddingRight', getAttr(body, 'data-padding-right') || '') removeAttr(body, 'data-padding-right') } } } }) // Create and export our modal manager instance export const modalManager = new ModalManager()