Skip to content

Commit 891e8cc

Browse files
jacobmllr95tmorehouse
authored andcommitted
fix(modal): stacked modal z-index calculations (closes #3015) (#3017)
1 parent d903008 commit 891e8cc

File tree

7 files changed

+544
-291
lines changed

7 files changed

+544
-291
lines changed

docs/plugins/bootstrap-vue.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
import Vue from 'vue'
22
import BootstrapVue from '../../src'
33

4-
Vue.use(BootstrapVue)
4+
Vue.use(BootstrapVue, {})
Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
//
2+
// private modalManager helper
3+
//
4+
// Handles controlling modal stacking zIndexes and body adjustments/classes
5+
//
6+
import Vue from 'vue'
7+
import { inBrowser } from '../../../utils/env'
8+
import {
9+
getAttr,
10+
hasAttr,
11+
removeAttr,
12+
setAttr,
13+
addClass,
14+
removeClass,
15+
getBCR,
16+
getCS,
17+
selectAll,
18+
requestAF
19+
} from '../../../utils/dom'
20+
21+
// Default modal backdrop z-index
22+
const DEFAULT_ZINDEX = 1040
23+
24+
// Selectors for padding/margin adjustments
25+
const Selector = {
26+
FIXED_CONTENT: '.fixed-top, .fixed-bottom, .is-fixed, .sticky-top',
27+
STICKY_CONTENT: '.sticky-top',
28+
NAVBAR_TOGGLER: '.navbar-toggler'
29+
}
30+
31+
const ModalManager = Vue.extend({
32+
data() {
33+
return {
34+
modals: [],
35+
baseZIndex: null,
36+
scrollbarWidth: null,
37+
isBodyOverflowing: false
38+
}
39+
},
40+
computed: {
41+
modalCount() {
42+
return this.modals.length
43+
},
44+
modalsAreOpen() {
45+
return this.modalCount > 0
46+
}
47+
},
48+
watch: {
49+
modalCount(newCount, oldCount) {
50+
if (inBrowser) {
51+
this.getScrollbarWidth()
52+
if (newCount > 0 && oldCount === 0) {
53+
// Transitioning to modal(s) open
54+
this.checkScrollbar()
55+
this.setScrollbar()
56+
addClass(document.body, 'modal-open')
57+
} else if (newCount === 0 && oldCount > 0) {
58+
// Transitioning to modal(s) closed
59+
this.resetScrollbar()
60+
removeClass(document.body, 'modal-open')
61+
}
62+
setAttr(document.body, 'data-modal-open-count', String(newCount))
63+
}
64+
},
65+
modals(newVal, oldVal) {
66+
this.checkScrollbar()
67+
requestAF(() => {
68+
this.updateModals(newVal || [])
69+
})
70+
}
71+
},
72+
methods: {
73+
// Public methods
74+
registerModal(modal) {
75+
if (modal && this.modals.indexOf(modal) === -1) {
76+
// Add modal to modals array
77+
this.modals.push(modal)
78+
modal.$once('hook:beforeDestroy', () => {
79+
this.unregisterModal(modal)
80+
})
81+
}
82+
},
83+
unregisterModal(modal) {
84+
const index = this.modals.indexOf(modal)
85+
if (index > -1) {
86+
// Remove modal from modals arary
87+
this.modals.splice(index, 1)
88+
// Reset the modal's data
89+
if (!(modal._isBeingDestroyed || modal._isDestroyed)) {
90+
this.resetModal(modal)
91+
}
92+
}
93+
},
94+
getBaseZIndex() {
95+
if (this.baseZIndex === null && inBrowser) {
96+
// Create a temporary div.modal-backdrop to get computed z-index
97+
const div = document.createElement('div')
98+
div.className = 'modal-backdrop d-none'
99+
div.style.display = 'none'
100+
document.body.appendChild(div)
101+
this.baseZIndex = parseInt(getCS(div).zIndex || DEFAULT_ZINDEX, 10)
102+
document.body.removeChild(div)
103+
}
104+
return this.baseZIndex || DEFAULT_ZINDEX
105+
},
106+
getScrollbarWidth() {
107+
if (this.scrollbarWidth === null && inBrowser) {
108+
// Create a temporary div.measure-scrollbar to get computed z-index
109+
const div = document.createElement('div')
110+
div.className = 'modal-scrollbar-measure'
111+
document.body.appendChild(div)
112+
this.scrollbarWidth = getBCR(div).width - div.clientWidth
113+
document.body.removeChild(div)
114+
}
115+
return this.scrollbarWidth || 0
116+
},
117+
// Private methods
118+
updateModals(modals) {
119+
const baseZIndex = this.getBaseZIndex()
120+
const scrollbarWidth = this.getScrollbarWidth()
121+
modals.forEach((modal, index) => {
122+
// We update data values on each modal
123+
modal.zIndex = baseZIndex + index
124+
modal.scrollbarWidth = scrollbarWidth
125+
modal.isTop = index === this.modals.length - 1
126+
modal.isBodyOverflowing = this.isBodyOverflowing
127+
})
128+
},
129+
resetModal(modal) {
130+
if (modal) {
131+
modal.zIndex = this.getBaseZIndex()
132+
modal.isTop = true
133+
modal.isBodyOverflowing = false
134+
}
135+
},
136+
checkScrollbar() {
137+
// Determine if the body element is overflowing
138+
// const { left, right, height } = getBCR(document.body)
139+
// Extra check for body.height needed for stacked modals
140+
// this.isBodyOverflowing = left + right < window.innerWidth || height > window.innerHeight
141+
const { left, right } = getBCR(document.body)
142+
this.isBodyOverflowing = left + right < window.innerWidth
143+
},
144+
setScrollbar() {
145+
const body = document.body
146+
// Storage place to cache changes to margins and padding
147+
// Note: This assumes the following element types are not added to the
148+
// document after the modal has opened.
149+
body._paddingChangedForModal = body._paddingChangedForModal || []
150+
body._marginChangedForModal = body._marginChangedForModal || []
151+
if (this.isBodyOverflowing) {
152+
const scrollbarWidth = this.scrollbarWidth
153+
// Adjust fixed content padding
154+
/* istanbul ignore next: difficult to test in JSDOM */
155+
selectAll(Selector.FIXED_CONTENT).forEach(el => {
156+
const actualPadding = el.style.paddingRight
157+
const calculatedPadding = getCS(el).paddingRight || 0
158+
setAttr(el, 'data-padding-right', actualPadding)
159+
el.style.paddingRight = `${parseFloat(calculatedPadding) + scrollbarWidth}px`
160+
body._paddingChangedForModal.push(el)
161+
})
162+
// Adjust sticky content margin
163+
/* istanbul ignore next: difficult to test in JSDOM */
164+
selectAll(Selector.STICKY_CONTENT).forEach(el => {
165+
const actualMargin = el.style.marginRight
166+
const calculatedMargin = getCS(el).marginRight || 0
167+
setAttr(el, 'data-margin-right', actualMargin)
168+
el.style.marginRight = `${parseFloat(calculatedMargin) - scrollbarWidth}px`
169+
body._marginChangedForModal.push(el)
170+
})
171+
// Adjust navbar-toggler margin
172+
/* istanbul ignore next: difficult to test in JSDOM */
173+
selectAll(Selector.NAVBAR_TOGGLER).forEach(el => {
174+
const actualMargin = el.style.marginRight
175+
const calculatedMargin = getCS(el).marginRight || 0
176+
setAttr(el, 'data-margin-right', actualMargin)
177+
el.style.marginRight = `${parseFloat(calculatedMargin) + scrollbarWidth}px`
178+
body._marginChangedForModal.push(el)
179+
})
180+
// Adjust body padding
181+
const actualPadding = body.style.paddingRight
182+
const calculatedPadding = getCS(body).paddingRight
183+
setAttr(body, 'data-padding-right', actualPadding)
184+
body.style.paddingRight = `${parseFloat(calculatedPadding) + scrollbarWidth}px`
185+
}
186+
},
187+
resetScrollbar() {
188+
const body = document.body
189+
if (body._paddingChangedForModal) {
190+
// Restore fixed content padding
191+
body._paddingChangedForModal.forEach(el => {
192+
/* istanbul ignore next: difficult to test in JSDOM */
193+
if (hasAttr(el, 'data-padding-right')) {
194+
el.style.paddingRight = getAttr(el, 'data-padding-right') || ''
195+
removeAttr(el, 'data-padding-right')
196+
}
197+
})
198+
}
199+
if (body._marginChangedForModal) {
200+
// Restore sticky content and navbar-toggler margin
201+
body._marginChangedForModal.forEach(el => {
202+
/* istanbul ignore next: difficult to test in JSDOM */
203+
if (hasAttr(el, 'data-margin-right')) {
204+
el.style.marginRight = getAttr(el, 'data-margin-right') || ''
205+
removeAttr(el, 'data-margin-right')
206+
}
207+
})
208+
}
209+
body._paddingChangedForModal = null
210+
body._marginChangedForModal = null
211+
// Restore body padding
212+
if (hasAttr(body, 'data-padding-right')) {
213+
body.style.paddingRight = getAttr(body, 'data-padding-right') || ''
214+
removeAttr(body, 'data-padding-right')
215+
}
216+
}
217+
}
218+
})
219+
220+
// Export our Modal Manager
221+
export default new ModalManager()

0 commit comments

Comments
 (0)