diff --git a/docs/plugins/bootstrap-vue.js b/docs/plugins/bootstrap-vue.js index 4b3ae0e1349..f6e69ddd063 100644 --- a/docs/plugins/bootstrap-vue.js +++ b/docs/plugins/bootstrap-vue.js @@ -1,4 +1,4 @@ import Vue from 'vue' import BootstrapVue from '../../src' -Vue.use(BootstrapVue) +Vue.use(BootstrapVue, {}) diff --git a/src/components/modal/helpers/modal-manager.js b/src/components/modal/helpers/modal-manager.js new file mode 100644 index 00000000000..a452bbe9cb1 --- /dev/null +++ b/src/components/modal/helpers/modal-manager.js @@ -0,0 +1,221 @@ +// +// private modalManager helper +// +// Handles controlling modal stacking zIndexes and body adjustments/classes +// +import Vue from 'vue' +import { inBrowser } from '../../../utils/env' +import { + getAttr, + hasAttr, + removeAttr, + setAttr, + addClass, + removeClass, + getBCR, + getCS, + selectAll, + requestAF +} from '../../../utils/dom' + +// 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', + STICKY_CONTENT: '.sticky-top', + NAVBAR_TOGGLER: '.navbar-toggler' +} + +const ModalManager = Vue.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 (inBrowser) { + 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(newVal, oldVal) { + this.checkScrollbar() + requestAF(() => { + this.updateModals(newVal || []) + }) + } + }, + methods: { + // Public methods + registerModal(modal) { + if (modal && this.modals.indexOf(modal) === -1) { + // Add modal to modals array + this.modals.push(modal) + modal.$once('hook:beforeDestroy', () => { + this.unregisterModal(modal) + }) + } + }, + unregisterModal(modal) { + const index = this.modals.indexOf(modal) + if (index > -1) { + // Remove modal from modals arary + this.modals.splice(index, 1) + // Reset the modal's data + if (!(modal._isBeingDestroyed || modal._isDestroyed)) { + this.resetModal(modal) + } + } + }, + getBaseZIndex() { + if (this.baseZIndex === null && inBrowser) { + // Create a temporary div.modal-backdrop to get computed z-index + const div = document.createElement('div') + div.className = 'modal-backdrop d-none' + div.style.display = 'none' + document.body.appendChild(div) + this.baseZIndex = parseInt(getCS(div).zIndex || DEFAULT_ZINDEX, 10) + document.body.removeChild(div) + } + return this.baseZIndex || DEFAULT_ZINDEX + }, + getScrollbarWidth() { + if (this.scrollbarWidth === null && inBrowser) { + // Create a temporary div.measure-scrollbar to get computed z-index + const div = document.createElement('div') + div.className = '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, height } = getBCR(document.body) + // Extra check for body.height needed for stacked modals + // this.isBodyOverflowing = left + right < window.innerWidth || height > window.innerHeight + 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 = el.style.paddingRight + const calculatedPadding = getCS(el).paddingRight || 0 + setAttr(el, 'data-padding-right', actualPadding) + el.style.paddingRight = `${parseFloat(calculatedPadding) + scrollbarWidth}px` + body._paddingChangedForModal.push(el) + }) + // Adjust sticky content margin + /* istanbul ignore next: difficult to test in JSDOM */ + selectAll(Selector.STICKY_CONTENT).forEach(el => { + const actualMargin = el.style.marginRight + const calculatedMargin = getCS(el).marginRight || 0 + setAttr(el, 'data-margin-right', actualMargin) + el.style.marginRight = `${parseFloat(calculatedMargin) - scrollbarWidth}px` + body._marginChangedForModal.push(el) + }) + // Adjust navbar-toggler margin + /* istanbul ignore next: difficult to test in JSDOM */ + selectAll(Selector.NAVBAR_TOGGLER).forEach(el => { + const actualMargin = el.style.marginRight + const calculatedMargin = getCS(el).marginRight || 0 + setAttr(el, 'data-margin-right', actualMargin) + el.style.marginRight = `${parseFloat(calculatedMargin) + scrollbarWidth}px` + body._marginChangedForModal.push(el) + }) + // Adjust body padding + const actualPadding = body.style.paddingRight + const calculatedPadding = getCS(body).paddingRight + setAttr(body, 'data-padding-right', actualPadding) + body.style.paddingRight = `${parseFloat(calculatedPadding) + 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')) { + el.style.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')) { + el.style.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')) { + body.style.paddingRight = getAttr(body, 'data-padding-right') || '' + removeAttr(body, 'data-padding-right') + } + } + } +}) + +// Export our Modal Manager +export default new ModalManager() diff --git a/src/components/modal/modal.js b/src/components/modal/modal.js index b0492a5b175..41dfd58d249 100644 --- a/src/components/modal/modal.js +++ b/src/components/modal/modal.js @@ -1,42 +1,22 @@ import Vue from 'vue' import BButton from '../button/button' import BButtonClose from '../button/button-close' +import modalManager from './helpers/modal-manager' import idMixin from '../../mixins/id' import listenOnRootMixin from '../../mixins/listen-on-root' import observeDom from '../../utils/observe-dom' import warn from '../../utils/warn' import KeyCodes from '../../utils/key-codes' import BvEvent from '../../utils/bv-event.class' +import { inBrowser } from '../../utils/env' import { getComponentConfig } from '../../utils/config' import { stripTags } from '../../utils/html' -import { - addClass, - contains, - eventOff, - eventOn, - getAttr, - getBCR, - getCS, - hasAttr, - hasClass, - isVisible, - removeAttr, - removeClass, - select, - selectAll, - setAttr -} from '../../utils/dom' +import { contains, eventOff, eventOn, isVisible, select } from '../../utils/dom' const NAME = 'BModal' -// Selectors for padding/margin adjustments -const Selector = { - FIXED_CONTENT: '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top', - STICKY_CONTENT: '.sticky-top', - NAVBAR_TOGGLER: '.navbar-toggler' -} - -// ObserveDom config +// ObserveDom config to detect changes in modal content +// so that we can adjust the modal padding if needed const OBSERVER_CONFIG = { subtree: true, childList: true, @@ -45,44 +25,6 @@ const OBSERVER_CONFIG = { attributeFilter: ['style', 'class'] } -// modal wrapper ZINDEX offset incrememnt -const ZINDEX_OFFSET = 2000 - -// Modal open count helpers -function getModalOpenCount() { - return parseInt(getAttr(document.body, 'data-modal-open-count') || 0, 10) -} - -function setModalOpenCount(count) { - setAttr(document.body, 'data-modal-open-count', String(count)) - return count -} - -function incrementModalOpenCount() { - return setModalOpenCount(getModalOpenCount() + 1) -} - -function decrementModalOpenCount() { - return setModalOpenCount(Math.max(getModalOpenCount() - 1, 0)) -} - -// Returns the current visible modal highest z-index -function getModalMaxZIndex() { - return selectAll('div.modal') /* find all modals that are in document */ - .filter(isVisible) /* filter only visible ones */ - .map(m => m.parentElement) /* select the outer div */ - .reduce((max, el) => { - /* compute the highest z-index */ - return Math.max(max, parseInt(el.style.zIndex || 0, 10)) - }, 0) -} - -// Returns the next z-index to be used by a modal to ensure proper stacking -// regardless of document order. Increments by 2000 -function getModalNextZIndex() { - return getModalMaxZIndex() + ZINDEX_OFFSET -} - export const props = { title: { type: String, @@ -274,24 +216,23 @@ export default Vue.extend({ props, data() { return { - is_hidden: this.lazy || false, // for lazy modals - is_visible: false, // controls modal visible state + is_hidden: this.lazy || false, // For lazy modals + is_visible: false, // Controls modal visible state is_transitioning: false, // Used for style control is_show: false, // Used for style control is_block: false, // Used for style control - is_opening: false, // Semaphore for previnting incorrect modal open counts - is_closing: false, // Semapbore for preventing incorrect modal open counts + is_opening: false, // Semaphore for preventing incorrect modal open counts + is_closing: false, // Semaphore for preventing incorrect modal open counts + isModalOverflowing: false, + return_focus: this.returnFocus || null, + // The following items are controlled by the modalManager instance scrollbarWidth: 0, - zIndex: ZINDEX_OFFSET, // z-index for modal stacking - isTop: true, // If the modal is the topmost opened modal - isBodyOverflowing: false, - return_focus: this.returnFocus || null + zIndex: modalManager.getBaseZIndex(), + isTop: true, + isBodyOverflowing: false } }, computed: { - contentClasses() { - return ['modal-content', this.contentClass] - }, modalClasses() { return [ { @@ -302,6 +243,13 @@ export default Vue.extend({ this.modalClass ] }, + modalStyles() { + const sbWidth = `${this.scrollbarWidth}px` + return { + paddingLeft: !this.isBodyOverflowing && this.isModalOverflowing ? sbWidth : '', + paddingRight: this.isBodyOverflowing && !this.isModalOverflowing ? sbWidth : '' + } + }, dialogClasses() { return [ { @@ -348,8 +296,8 @@ export default Vue.extend({ ] }, modalOuterStyle() { + // Styles needed for proper stacking of modals return { - // We only set these styles on the stacked modals (ones with next z-index > 0). position: 'absolute', zIndex: this.zIndex } @@ -363,18 +311,19 @@ export default Vue.extend({ } }, created() { - // create non-reactive property + // Define non-reactive properties this._observer = null }, mounted() { + // Set initial z-index as queried from the DOM + this.zIndex = modalManager.getBaseZIndex() // Listen for events from others to either open or close ourselves - // And listen to all modals to enable/disable enforce focus + // and listen to all modals to enable/disable enforce focus this.listenOnRoot('bv::show::modal', this.showHandler) - this.listenOnRoot('bv::modal::shown', this.shownHandler) this.listenOnRoot('bv::hide::modal', this.hideHandler) - this.listenOnRoot('bv::modal::hidden', this.hiddenHandler) this.listenOnRoot('bv::toggle::modal', this.toggleHandler) - // Listen for bv:modal::show events, and close ourselves if the opening modal not us + // Listen for `bv:modal::show events`, and close ourselves if the + // opening modal not us this.listenOnRoot('bv::modal::show', this.modalListener) // Initially show modal? if (this.visible === true) { @@ -387,33 +336,24 @@ export default Vue.extend({ this._observer.disconnect() this._observer = null } - // Ensure our root "once" listener is gone - this.$root.$off('bv::modal::hidden', this.doShow) this.setEnforceFocus(false) this.setResizeEvent(false) if (this.is_visible) { this.is_visible = false this.is_show = false this.is_transitioning = false - const count = decrementModalOpenCount() - if (count === 0) { - // Re-adjust body/navbar/fixed padding/margins (as we were the last modal open) - this.setModalOpenClass(false) - this.resetScrollbar() - this.resetDialogAdjustments() - } } }, methods: { // Public Methods show() { if (this.is_visible || this.is_opening) { - // if already open, on in the process of opening, do nothing + // If already open, on in the process of opening, do nothing /* istanbul ignore next */ return } if (this.is_closing) { - // if we are in the process of closing, wait until hidden before re-opening + // If we are in the process of closing, wait until hidden before re-opening /* istanbul ignore next: very difficult to test */ this.$once('hidden', this.show) /* istanbul ignore next */ @@ -424,8 +364,9 @@ export default Vue.extend({ cancelable: true, vueTarget: this, target: this.$refs.modal, - modalId: this.safeId(), - relatedTarget: null + relatedTarget: null, + // Modal specifi properties + modalId: this.safeId() }) this.emitEvent(showEvt) // Don't show if canceled @@ -433,14 +374,6 @@ export default Vue.extend({ this.is_opening = false return } - if (!this.noStacking) { - // Find the z-index to use - this.zIndex = getModalNextZIndex() - } else if (hasClass(document.body, 'modal-open')) { - // If another modal is already open, wait for it to close - this.$root.$once('bv::modal::hidden', this.doShow) - return - } // Show the modal this.doShow() }, @@ -451,13 +384,13 @@ export default Vue.extend({ } this.is_closing = true const hideEvt = new BvEvent('hide', { + // BvEvent standard properties cancelable: true, vueTarget: this, target: this.$refs.modal, - modalId: this.safeId(), - // this could be the trigger element/component reference relatedTarget: null, - isOK: trigger || null, + // Modal specific properties and methods + modalId: this.safeId(), trigger: trigger || null, cancel() /* istanbul ignore next */ { // Backwards compatibility @@ -465,10 +398,13 @@ export default Vue.extend({ this.preventDefault() } }) + // We emit specific event for one of the three built-in buttons if (trigger === 'ok') { this.$emit('ok', hideEvt) } else if (trigger === 'cancel') { this.$emit('cancel', hideEvt) + } else if (trigger === 'headerclose') { + this.$emit('close', hideEvt) } this.emitEvent(hideEvt) // Hide if not canceled @@ -476,12 +412,13 @@ export default Vue.extend({ this.is_closing = false return } - // stop observing for content changes + // Stop observing for content changes if (this._observer) { this._observer.disconnect() this._observer = null } this.is_visible = false + // Update the v-model this.$emit('change', false) }, // Public method to toggle modal visibility @@ -497,32 +434,34 @@ export default Vue.extend({ }, // Private method to finish showing modal doShow() { + /* istanbul ignore next: commenting out for now until we can test stacking */ + if (modalManager.modalsAreOpen && this.noStacking) { + // If another modal(s) is already open, wait for it(them) to close + this.listenOnRootOnce('bv::modal::hidden', this.doShow) + return + } // Place modal in DOM if lazy this.is_hidden = false this.$nextTick(() => { - // We do this in nextTick to ensure the modal is in DOM first before we show it + // We do this in `$nextTick()` to ensure the modal is in DOM first + // before we show it this.is_visible = true this.is_opening = false + // Update the v-model this.$emit('change', true) // Observe changes in modal content and adjust if necessary this._observer = observeDom( this.$refs.content, - this.adjustDialog.bind(this), + this.checkModalOverflow.bind(this), OBSERVER_CONFIG ) }) }, - // Transition Handlers + // Transition handlers onBeforeEnter() { - this.getScrollbarWidth() this.is_transitioning = true - this.checkScrollbar() - const count = incrementModalOpenCount() - if (count === 1) { - this.setScrollbar() - this.setModalOpenClass(true) - } - this.adjustDialog() + modalManager.registerModal(this) + this.checkModalOverflow() this.setResizeEvent(true) }, onEnter() { @@ -536,8 +475,8 @@ export default Vue.extend({ cancelable: false, vueTarget: this, target: this.$refs.modal, - modalId: this.safeId(), - relatedTarget: null + relatedTarget: null, + modalId: this.safeId() }) this.emitEvent(shownEvt) this.focusFirst() @@ -554,39 +493,33 @@ export default Vue.extend({ }, onAfterLeave() { this.is_block = false - this.resetDialogAdjustments() this.is_transitioning = false - const count = decrementModalOpenCount() - if (count === 0) { - this.resetScrollbar() - this.setModalOpenClass(false) - } this.setEnforceFocus(false) + this.isModalOverflowing = false this.$nextTick(() => { - this.is_hidden = this.lazy || false - this.zIndex = ZINDEX_OFFSET this.returnFocusTo() this.is_closing = false const hiddenEvt = new BvEvent('hidden', { cancelable: false, vueTarget: this, target: this.lazy ? null : this.$refs.modal, - modalId: this.safeId(), - relatedTarget: null + relatedTarget: null, + modalId: this.safeId() }) this.emitEvent(hiddenEvt) + modalManager.unregisterModal(this) }) }, // Event emitter emitEvent(bvEvt) { const type = bvEvt.type this.$emit(type, bvEvt) - this.$root.$emit(`bv::modal::${type}`, bvEvt, this.safeId()) + this.emitOnRoot(`bv::modal::${type}`, bvEvt, bvEvt.modalId) }, - // UI Event Handlers + // UI event handlers onClickOut(evt) { - // Do nothing if not visible, backdrop click disabled, or element that generated - // click event is no longer in document + // Do nothing if not visible, backdrop click disabled, or element + // that generated click event is no longer in document if (!this.is_visible || this.noCloseOnBackdrop || !contains(document, evt.target)) { return } @@ -621,14 +554,15 @@ export default Vue.extend({ const method = on ? eventOn : eventOff method(document, 'focusin', this.focusHandler, { passive: true, capture: false }) }, - // Resize Listener + // Resize listener setResizeEvent(on) { const options = { passive: true, capture: false } const method = on ? eventOn : eventOff - method(window, 'resize', this.adjustDialog, options) - method(window, 'orientationchange', this.adjustDialog, options) + // These events should probably also check if body is overflowing + method(window, 'resize', this.checkModalOverflow, options) + method(window, 'orientationchange', this.checkModalOverflow, options) }, - // Root Listener handlers + // Root listener handlers showHandler(id, triggerEl) { if (id === this.id) { this.return_focus = triggerEl || null @@ -645,46 +579,37 @@ export default Vue.extend({ this.toggle(triggerEl) } }, - shownHandler() { - this.setTop() - }, - hiddenHandler() { - this.setTop() - }, - setTop() { - // Determine if we are the topmost visible modal - this.isTop = this.zIndex >= getModalMaxZIndex() - }, modalListener(bvEvt) { - // If another modal opens, close this one + // If another modal opens, close this one if stacking not permitted if (this.noStacking && bvEvt.vueTarget !== this) { this.hide() } }, // Focus control handlers focusFirst() { + // TODO: + // Add support for finding input element with 'autofocus' attribute set + // and focus that element // Don't try and focus if we are SSR - if (typeof document === 'undefined') { - /* istanbul ignore next */ - return - } - const modal = this.$refs.modal - const activeElement = document.activeElement - if (activeElement && contains(modal, activeElement)) { - // If activeElement is child of modal or is modal, no need to change focus - return - } - if (modal) { - // make sure top of modal is showing (if longer than the viewport) and - // focus the modal content wrapper - this.$nextTick(() => { - modal.scrollTop = 0 - modal.focus() - }) + if (inBrowser) { + const modal = this.$refs.modal + const activeElement = document.activeElement + if (activeElement && contains(modal, activeElement)) { + // If `activeElement` is child of modal or is modal, no need to change focus + return + } + if (modal) { + // Make sure top of modal is showing (if longer than the viewport) + // and focus the modal content wrapper + this.$nextTick(() => { + modal.scrollTop = 0 + modal.focus() + }) + } } }, returnFocusTo() { - // Prefer returnFocus prop over event specified return_focus value + // Prefer `returnFocus` prop over event specified `return_focus` value let el = this.returnFocus || this.return_focus || null if (typeof el === 'string') { // CSS Selector @@ -697,123 +622,16 @@ export default Vue.extend({ } } }, - // Utility methods - getScrollbarWidth() { - const scrollDiv = document.createElement('div') - scrollDiv.className = 'modal-scrollbar-measure' - document.body.appendChild(scrollDiv) - this.scrollbarWidth = getBCR(scrollDiv).width - scrollDiv.clientWidth - document.body.removeChild(scrollDiv) - }, - setModalOpenClass(open) { - const method = open ? addClass : removeClass - method(document.body, 'modal-open') - }, - adjustDialog() { - if (!this.is_visible) { - /* istanbul ignore next */ - return - } - const modal = this.$refs.modal - const isModalOverflowing = modal.scrollHeight > document.documentElement.clientHeight - if (!this.isBodyOverflowing && isModalOverflowing) { - modal.style.paddingLeft = `${this.scrollbarWidth}px` - } else { - modal.style.paddingLeft = '' - } - if (this.isBodyOverflowing && !isModalOverflowing) { - modal.style.paddingRight = `${this.scrollbarWidth}px` - } else { - modal.style.paddingRight = '' - } - }, - resetDialogAdjustments() { - const modal = this.$refs.modal - if (modal) { - modal.style.paddingLeft = '' - modal.style.paddingRight = '' - } - }, - checkScrollbar() { - const { left, right, height } = getBCR(document.body) - // Extra check for body.height needed for stacked modals - this.isBodyOverflowing = left + right < window.innerWidth || height > window.innerHeight - }, - 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 hte modal has opened. - body._paddingChangedForModal = body._paddingChangedForModal || [] - body._marginChangedForModal = body._marginChangedForModal || [] - /* istanbul ignore if: get Computed Style can't be tested in JSDOM */ - if (this.isBodyOverflowing) { - // Note: DOMNode.style.paddingRight returns the actual value or '' if not set - // while $(DOMNode).css('padding-right') returns the calculated value or 0 if not set - const scrollbarWidth = this.scrollbarWidth - // Adjust fixed content padding - selectAll(Selector.FIXED_CONTENT).forEach(el => { - const actualPadding = el.style.paddingRight - const calculatedPadding = getCS(el).paddingRight || 0 - setAttr(el, 'data-padding-right', actualPadding) - el.style.paddingRight = `${parseFloat(calculatedPadding) + scrollbarWidth}px` - body._paddingChangedForModal.push(el) - }) - // Adjust sticky content margin - selectAll(Selector.STICKY_CONTENT).forEach(el => { - const actualMargin = el.style.marginRight - const calculatedMargin = getCS(el).marginRight || 0 - setAttr(el, 'data-margin-right', actualMargin) - el.style.marginRight = `${parseFloat(calculatedMargin) - scrollbarWidth}px` - body._marginChangedForModal.push(el) - }) - // Adjust navbar-toggler margin - selectAll(Selector.NAVBAR_TOGGLER).forEach(el => { - const actualMargin = el.style.marginRight - const calculatedMargin = getCS(el).marginRight || 0 - setAttr(el, 'data-margin-right', actualMargin) - el.style.marginRight = `${parseFloat(calculatedMargin) + scrollbarWidth}px` - body._marginChangedForModal.push(el) - }) - // Adjust body padding - const actualPadding = body.style.paddingRight - const calculatedPadding = getCS(body).paddingRight - setAttr(body, 'data-padding-right', actualPadding) - body.style.paddingRight = `${parseFloat(calculatedPadding) + scrollbarWidth}px` - } - }, - resetScrollbar() { - const body = document.body - if (body._paddingChangedForModal) { - // Restore fixed content padding - body._paddingChangedForModal.forEach(el => { - if (hasAttr(el, 'data-padding-right')) { - el.style.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 => { - if (hasAttr(el, 'data-margin-right')) { - el.style.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')) { - body.style.paddingRight = getAttr(body, 'data-padding-right') || '' - removeAttr(body, 'data-padding-right') + checkModalOverflow() { + if (this.is_visible) { + const modal = this.$refs.modal + this.isModalOverflowing = modal.scrollHeight > document.documentElement.clientHeight } } }, render(h) { const $slots = this.$slots - // Modal Header + // Modal header let header = h(false) if (!this.hideHeader) { let modalHeader = $slots['modal-header'] @@ -855,7 +673,7 @@ export default Vue.extend({ [modalHeader] ) } - // Modal Body + // Modal body const body = h( 'div', { @@ -919,12 +737,13 @@ export default Vue.extend({ [modalFooter] ) } - // Assemble Modal Content + // Assemble modal content const modalContent = h( 'div', { ref: 'content', - class: this.contentClasses, + staticClass: 'modal-content', + class: this.contentClass, attrs: { role: 'document', id: this.safeId('__BV_modal_content_'), @@ -934,7 +753,7 @@ export default Vue.extend({ }, [header, body, footer] ) - // Modal Dialog wrapper + // Modal dialog wrapper const modalDialog = h( 'div', { @@ -950,6 +769,7 @@ export default Vue.extend({ ref: 'modal', staticClass: 'modal', class: this.modalClasses, + style: this.modalStyles, directives: [ { name: 'show', rawName: 'v-show', value: this.is_visible, expression: 'is_visible' } ], @@ -1005,7 +825,8 @@ export default Vue.extend({ [$slots['modal-backdrop']] ) } - // Tab trap to prevent page from scrolling to next element in tab index during enforce focus tab cycle + // Tab trap to prevent page from scrolling to next element in tab index + // during enforce focus tab cycle let tabTrap = h(false) if (this.is_visible && this.isTop && !this.noEnforceFocus) { tabTrap = h('div', { attrs: { tabindex: '0' } }) @@ -1023,7 +844,7 @@ export default Vue.extend({ [modal, tabTrap, backdrop] ) } - // Wrap in DIV to maintain thi.$el reference for hide/show method aceess + // Wrap in DIV to maintain `this.$el` reference for hide/show method access return h('div', {}, [outer]) } }) diff --git a/src/components/modal/modal.spec.js b/src/components/modal/modal.spec.js index d095ca62bbd..edd3ffd1dd0 100644 --- a/src/components/modal/modal.spec.js +++ b/src/components/modal/modal.spec.js @@ -1,12 +1,40 @@ import BModal from './modal' +import BvEvent from '../../utils/bv-event.class' + import { mount, createWrapper } from '@vue/test-utils' +// The defautl Z-INDEX for modal backdrop +const DEFAULT_ZINDEX = 1040 + const waitAF = () => new Promise(resolve => requestAnimationFrame(resolve)) describe('modal', () => { + const origGetBCR = Element.prototype.getBoundingClientRect + + beforeEach(() => { + // Mock getBCR so that the isVisible(el) test returns true + // Needed for z-index checks + Element.prototype.getBoundingClientRect = jest.fn(() => { + return { + width: 24, + height: 24, + top: 0, + left: 0, + bottom: 0, + right: 0 + } + }) + }) + + afterEach(() => { + // Restore prototype + Element.prototype.getBoundingClientRect = origGetBCR + }) + describe('structure', () => { it('has expected default structure', async () => { const wrapper = mount(BModal, { + attachToDocument: true, propsData: { id: 'test' } @@ -25,7 +53,7 @@ describe('modal', () => { expect($outer.is('div')).toBe(true) expect($outer.classes().length).toBe(0) expect($outer.element.style.position).toEqual('absolute') - expect($outer.element.style.zIndex).toEqual('2000') + expect($outer.element.style.zIndex).toEqual(`${DEFAULT_ZINDEX}`) // Should not have a backdrop expect($outer.find('div.modal-backdrop').exists()).toBe(false) @@ -56,6 +84,7 @@ describe('modal', () => { it('has expected structure when lazy', async () => { const wrapper = mount(BModal, { + attachToDocument: true, propsData: { lazy: true } @@ -96,12 +125,12 @@ describe('modal', () => { expect(wrapper.is('div')).toBe(true) expect(wrapper.classes().length).toBe(0) - // Main outer wrapper (has z-index, etc)... The stacker div + // Main outer wrapper (has z-index, etc)... the stacker div const $outer = createWrapper(wrapper.element.firstElementChild) expect($outer.is('div')).toBe(true) expect($outer.classes().length).toBe(0) expect($outer.element.style.position).toEqual('absolute') - expect($outer.element.style.zIndex).toEqual('2000') + expect($outer.element.style.zIndex).toEqual(`${DEFAULT_ZINDEX}`) // Main modal wrapper const $modal = $outer.find('div.modal') @@ -174,7 +203,7 @@ describe('modal', () => { expect($outer.is('div')).toBe(true) expect($outer.classes().length).toBe(0) expect($outer.element.style.position).toEqual('absolute') - expect($outer.element.style.zIndex).toEqual('2000') + expect($outer.element.style.zIndex).toEqual(`${DEFAULT_ZINDEX}`) // Main modal wrapper const $modal = $outer.find('div.modal') @@ -227,7 +256,9 @@ describe('modal', () => { describe('default button content, classes and attributes', () => { // We may want to move these tests into individual files for manageability it('default footer ok and cancel buttons', async () => { - const wrapper = mount(BModal) + const wrapper = mount(BModal, { + attachToDocument: true + }) expect(wrapper).toBeDefined() const $buttons = wrapper.findAll('footer button') @@ -251,7 +282,9 @@ describe('modal', () => { }) it('default header close button', async () => { - const wrapper = mount(BModal) + const wrapper = mount(BModal, { + attachToDocument: true + }) expect(wrapper).toBeDefined() const $buttons = wrapper.findAll('header button') @@ -271,6 +304,7 @@ describe('modal', () => { it('header close button triggers modal close and is preventable', async () => { let cancelHide = true let trigger = null + let evt = null const wrapper = mount(BModal, { attachToDocument: true, stubs: { @@ -286,6 +320,7 @@ describe('modal', () => { bvEvent.preventDefault() } trigger = bvEvent.trigger + evt = bvEvent } } }) @@ -313,10 +348,12 @@ describe('modal', () => { expect(wrapper.emitted('hide')).not.toBeDefined() expect(trigger).toEqual(null) + expect(evt).toEqual(null) // Try and close modal (but we prevent it) $close.trigger('click') expect(trigger).toEqual('headerclose') + expect(evt).toBeInstanceOf(BvEvent) await wrapper.vm.$nextTick() await waitAF() @@ -329,8 +366,10 @@ describe('modal', () => { // Try and close modal (and not prevent it) cancelHide = false trigger = null + evt = null $close.trigger('click') expect(trigger).toEqual('headerclose') + expect(evt).toBeInstanceOf(BvEvent) await wrapper.vm.$nextTick() await waitAF() @@ -590,5 +629,74 @@ describe('modal', () => { wrapper.destroy() }) + + it('show event is cancellable', async () => { + let prevent = true + let called = 0 + const wrapper = mount(BModal, { + attachToDocument: true, + stubs: { + transition: false + }, + propsData: { + id: 'test', + visible: false + } + }) + + expect(wrapper.isVueInstance()).toBe(true) + + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await waitAF() + + const $modal = wrapper.find('div.modal') + expect($modal.exists()).toBe(true) + + expect($modal.element.style.display).toEqual('none') + + wrapper.vm.$on('show', bvEvt => { + called = true + if (prevent) { + bvEvt.preventDefault() + } + }) + + // Try and open modal via `bv::show::modal` + wrapper.vm.$root.$emit('bv::show::modal', 'test') + + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await waitAF() + + // Modal should not open + expect(called).toBe(true) + expect($modal.element.style.display).toEqual('none') + + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await waitAF() + + // Allow modal to open + prevent = false + called = false + + // Try and open modal via `bv::show::modal` + wrapper.vm.$root.$emit('bv::show::modal', 'test') + + await wrapper.vm.$nextTick() + await waitAF() + await wrapper.vm.$nextTick() + await waitAF() + + // Modal should now be open + expect(called).toBe(true) + expect($modal.element.style.display).toEqual('') + + wrapper.destroy() + }) }) }) diff --git a/src/mixins/listen-on-root.js b/src/mixins/listen-on-root.js index a4390ad78f6..42af1e50466 100644 --- a/src/mixins/listen-on-root.js +++ b/src/mixins/listen-on-root.js @@ -30,6 +30,30 @@ export default { return this }, + /** + * Safely register a $once event listener on the root Vue node. + * While Vue automatically removes listeners for individual components, + * when a component registers a listener on root and is destroyed, + * this orphans a callback because the node is gone, + * but the root does not clear the callback. + * + * When registering a $root listener, it also registers a listener on + * the component's `beforeDestroy` hook to automatically remove the + * event listener from the $root instance. + * + * @param {string} event + * @param {function} callback + * @chainable + */ + listenOnRootOnce(event, callback) { + this.$root.$once(event, callback) + this.$on('hook:beforeDestroy', () => { + this.$root.$off(event, callback) + }) + // Return this for easy chaining + return this + }, + /** * Convenience method for calling vm.$emit on vm.$root. * @param {string} event diff --git a/src/mixins/listen-on-root.spec.js b/src/mixins/listen-on-root.spec.js new file mode 100644 index 00000000000..2f084a45715 --- /dev/null +++ b/src/mixins/listen-on-root.spec.js @@ -0,0 +1,73 @@ +import listenOnRootMixin from './listen-on-root' +import { mount, createLocalVue as CreateLocalVue } from '@vue/test-utils' + +describe('mixins/listen-on-root', () => { + const localVue = new CreateLocalVue() + it('works', async () => { + const spyOn = jest.fn() + const spyOnce = jest.fn() + + const TestComponent = localVue.extend({ + mixins: [listenOnRootMixin], + created() { + this.listenOnRoot('root-on', spyOn) + this.listenOnRootOnce('root-once', spyOnce) + }, + render(h) { + return h('div', {}, this.$slots.default) + } + }) + + const App = localVue.extend({ + components: { TestComponent }, + props: { + destroy: { + type: Boolean, + default: false + } + }, + render(h) { + return h('div', {}, [this.destroy ? h(false) : h(TestComponent, {}, 'test-component')]) + } + }) + + const wrapper = mount(App, { + localVue: localVue, + propsData: { + destroy: false + } + }) + + expect(wrapper.isVueInstance()).toBe(true) + expect(wrapper.text()).toEqual('test-component') + + expect(spyOn).not.toHaveBeenCalled() + expect(spyOnce).not.toHaveBeenCalled() + + const $root = wrapper.vm.$root + + $root.$emit('root-on') + + expect(spyOn).toHaveBeenCalledTimes(1) + expect(spyOnce).not.toHaveBeenCalled() + + wrapper.setProps({ + destroy: true + }) + + expect(spyOn).toHaveBeenCalledTimes(1) + expect(spyOnce).not.toHaveBeenCalled() + + $root.$emit('root-on') + + expect(spyOn).toHaveBeenCalledTimes(1) + expect(spyOnce).not.toHaveBeenCalled() + + $root.$emit('root-once') + + expect(spyOn).toHaveBeenCalledTimes(1) + expect(spyOnce).not.toHaveBeenCalled() + + wrapper.destroy() + }) +}) diff --git a/src/utils/dom.js b/src/utils/dom.js index 500b1160844..ce35e82c61e 100644 --- a/src/utils/dom.js +++ b/src/utils/dom.js @@ -56,12 +56,18 @@ export const isElement = el => { } // Determine if an HTML element is visible - Faster than CSS check -export const isVisible = el => /* istanbul ignore next: getBoundingClientRect() doesn't work in JSDOM */ { +export const isVisible = el => { if (!isElement(el) || !contains(document.body, el)) { return false } + if (el.style.display === 'none') { + // We do this check to help with vue-test-utils when using v-show + /* istanbul ignore next */ + return false + } // All browsers support getBoundingClientRect(), except JSDOM as it returns all 0's for values :( // So any tests that need isVisible will fail in JSDOM + // Except when we override the getBCR prototype in some tests const bcr = getBCR(el) return Boolean(bcr && bcr.height > 0 && bcr.width > 0) }