Skip to content

Commit 390a5c7

Browse files
authored
fix(v-b-toggle): prevent scroll anchoring behavior (closes #5715) (#5769)
* fix(v-b-toggle): prevent scroll anchoring behavior * Update form-textarea.js * Update dom.js * Update dom.spec.js * Update dom.js * Update modal-manager.js * Update bv-collapse.js
1 parent 942bf31 commit 390a5c7

File tree

7 files changed

+98
-49
lines changed

7 files changed

+98
-49
lines changed

src/components/collapse/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ at a time.
143143

144144
```html
145145
<template>
146-
<div role="tablist">
146+
<div class="accordion" role="tablist">
147147
<b-card no-body class="mb-1">
148148
<b-card-header header-tag="header" class="p-1" role="tab">
149149
<b-button block v-b-toggle.accordion-1 variant="info">Accordion 1</b-button>

src/components/form-textarea/form-textarea.js

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import Vue from '../../utils/vue'
2-
import { getCS, isVisible, requestAF } from '../../utils/dom'
2+
import { getCS, getStyle, isVisible, requestAF, setStyle } from '../../utils/dom'
33
import { isNull } from '../../utils/inspect'
44
import { mathCeil, mathMax, mathMin } from '../../utils/math'
55
import { toInteger, toFloat } from '../../utils/number'
@@ -171,13 +171,13 @@ export const BFormTextarea = /*#__PURE__*/ Vue.extend({
171171
const minHeight = lineHeight * this.computedMinRows + offset
172172

173173
// Get the current style height (with `px` units)
174-
const oldHeight = el.style.height || computedStyle.height
174+
const oldHeight = getStyle(el, 'height') || computedStyle.height
175175
// Probe scrollHeight by temporarily changing the height to `auto`
176-
el.style.height = 'auto'
176+
setStyle(el, 'auto')
177177
const scrollHeight = el.scrollHeight
178178
// Place the original old height back on the element, just in case `computedProp`
179179
// returns the same value as before
180-
el.style.height = oldHeight
180+
setStyle(el, oldHeight)
181181

182182
// Calculate content height in 'rows' (scrollHeight includes padding but not border)
183183
const contentRows = mathMax((scrollHeight - padding) / lineHeight, 2)

src/components/modal/helpers/modal-manager.js

+22-19
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,18 @@
55

66
import Vue from '../../../utils/vue'
77
import {
8+
addClass,
89
getAttr,
10+
getBCR,
11+
getCS,
12+
getStyle,
913
hasAttr,
1014
removeAttr,
11-
setAttr,
12-
addClass,
1315
removeClass,
14-
getBCR,
15-
getCS,
16+
requestAF,
1617
selectAll,
17-
requestAF
18+
setAttr,
19+
setStyle
1820
} from '../../../utils/dom'
1921
import { isBrowser } from '../../../utils/env'
2022
import { isNull } from '../../../utils/inspect'
@@ -101,8 +103,9 @@ const ModalManager = /*#__PURE__*/ Vue.extend({
101103
if (isNull(this.baseZIndex) && isBrowser) {
102104
// Create a temporary `div.modal-backdrop` to get computed z-index
103105
const div = document.createElement('div')
104-
div.className = 'modal-backdrop d-none'
105-
div.style.display = 'none'
106+
addClass(div, 'modal-backdrop')
107+
addClass(div, 'd-none')
108+
setStyle(div, 'display', 'none')
106109
document.body.appendChild(div)
107110
this.baseZIndex = toInteger(getCS(div).zIndex, DEFAULT_ZINDEX)
108111
document.body.removeChild(div)
@@ -113,7 +116,7 @@ const ModalManager = /*#__PURE__*/ Vue.extend({
113116
if (isNull(this.scrollbarWidth) && isBrowser) {
114117
// Create a temporary `div.measure-scrollbar` to get computed z-index
115118
const div = document.createElement('div')
116-
div.className = 'modal-scrollbar-measure'
119+
addClass(div, 'modal-scrollbar-measure')
117120
document.body.appendChild(div)
118121
this.scrollbarWidth = getBCR(div).width - div.clientWidth
119122
document.body.removeChild(div)
@@ -156,31 +159,31 @@ const ModalManager = /*#__PURE__*/ Vue.extend({
156159
// Adjust fixed content padding
157160
/* istanbul ignore next: difficult to test in JSDOM */
158161
selectAll(Selector.FIXED_CONTENT).forEach(el => {
159-
const actualPadding = el.style.paddingRight
162+
const actualPadding = getStyle(el, 'paddingRight')
160163
setAttr(el, 'data-padding-right', actualPadding)
161-
el.style.paddingRight = `${toFloat(getCS(el).paddingRight, 0) + scrollbarWidth}px`
164+
setStyle(el, 'paddingRight', `${toFloat(getCS(el).paddingRight, 0) + scrollbarWidth}px`)
162165
body._paddingChangedForModal.push(el)
163166
})
164167
// Adjust sticky content margin
165168
/* istanbul ignore next: difficult to test in JSDOM */
166169
selectAll(Selector.STICKY_CONTENT).forEach(el => /* istanbul ignore next */ {
167-
const actualMargin = el.style.marginRight
170+
const actualMargin = getStyle(el, 'marginRight')
168171
setAttr(el, 'data-margin-right', actualMargin)
169-
el.style.marginRight = `${toFloat(getCS(el).marginRight, 0) - scrollbarWidth}px`
172+
setStyle(el, 'marginRight', `${toFloat(getCS(el).marginRight, 0) - scrollbarWidth}px`)
170173
body._marginChangedForModal.push(el)
171174
})
172175
// Adjust <b-navbar-toggler> margin
173176
/* istanbul ignore next: difficult to test in JSDOM */
174177
selectAll(Selector.NAVBAR_TOGGLER).forEach(el => /* istanbul ignore next */ {
175-
const actualMargin = el.style.marginRight
178+
const actualMargin = getStyle(el, 'marginRight')
176179
setAttr(el, 'data-margin-right', actualMargin)
177-
el.style.marginRight = `${toFloat(getCS(el).marginRight, 0) + scrollbarWidth}px`
180+
setStyle(el, 'marginRight', `${toFloat(getCS(el).marginRight, 0) + scrollbarWidth}px`)
178181
body._marginChangedForModal.push(el)
179182
})
180183
// Adjust body padding
181-
const actualPadding = body.style.paddingRight
184+
const actualPadding = getStyle(body, 'paddingRight')
182185
setAttr(body, 'data-padding-right', actualPadding)
183-
body.style.paddingRight = `${toFloat(getCS(body).paddingRight, 0) + scrollbarWidth}px`
186+
setStyle(body, 'paddingRight', `${toFloat(getCS(body).paddingRight, 0) + scrollbarWidth}px`)
184187
}
185188
},
186189
resetScrollbar() {
@@ -190,7 +193,7 @@ const ModalManager = /*#__PURE__*/ Vue.extend({
190193
body._paddingChangedForModal.forEach(el => {
191194
/* istanbul ignore next: difficult to test in JSDOM */
192195
if (hasAttr(el, 'data-padding-right')) {
193-
el.style.paddingRight = getAttr(el, 'data-padding-right') || ''
196+
setStyle(el, 'paddingRight', getAttr(el, 'data-padding-right') || '')
194197
removeAttr(el, 'data-padding-right')
195198
}
196199
})
@@ -200,7 +203,7 @@ const ModalManager = /*#__PURE__*/ Vue.extend({
200203
body._marginChangedForModal.forEach(el => {
201204
/* istanbul ignore next: difficult to test in JSDOM */
202205
if (hasAttr(el, 'data-margin-right')) {
203-
el.style.marginRight = getAttr(el, 'data-margin-right') || ''
206+
setStyle(el, 'marginRight', getAttr(el, 'data-margin-right') || '')
204207
removeAttr(el, 'data-margin-right')
205208
}
206209
})
@@ -209,7 +212,7 @@ const ModalManager = /*#__PURE__*/ Vue.extend({
209212
body._marginChangedForModal = null
210213
// Restore body padding
211214
if (hasAttr(body, 'data-padding-right')) {
212-
body.style.paddingRight = getAttr(body, 'data-padding-right') || ''
215+
setStyle(body, 'paddingRight', getAttr(body, 'data-padding-right') || '')
213216
removeAttr(body, 'data-padding-right')
214217
}
215218
}

src/directives/toggle/toggle.js

+14-4
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import {
99
isTag,
1010
removeAttr,
1111
removeClass,
12+
removeStyle,
1213
requestAF,
13-
setAttr
14+
setAttr,
15+
setStyle
1416
} from '../../utils/dom'
1517
import { isBrowser } from '../../utils/env'
1618
import { EVENT_OPTIONS_PASSIVE, eventOn, eventOff } from '../../utils/events'
@@ -46,6 +48,9 @@ const ATTR_ARIA_EXPANDED = 'aria-expanded'
4648
const ATTR_ROLE = 'role'
4749
const ATTR_TABINDEX = 'tabindex'
4850

51+
// Commonly used style properties
52+
const STYLE_OVERFLOW_ANCHOR = 'overflow-anchor'
53+
4954
// Emitted control event for collapse (emitted to collapse)
5055
export const EVENT_TOGGLE = 'bv::toggle::collapse'
5156

@@ -194,13 +199,17 @@ const handleUpdate = (el, binding, vnode) => {
194199
// Parse list of target IDs
195200
const targets = getTargets(binding, el)
196201

197-
/* istanbul ignore else */
198202
// Ensure the `aria-controls` hasn't been overwritten
199203
// or removed when vnode updates
200-
if (targets.length) {
204+
// Also ensure to set `overflow-anchor` to `none` to prevent
205+
// the browser's scroll anchoring behavior
206+
/* istanbul ignore else */
207+
if (targets.length > 0) {
201208
setAttr(el, ATTR_ARIA_CONTROLS, targets.join(' '))
209+
setStyle(el, STYLE_OVERFLOW_ANCHOR, 'none')
202210
} else {
203211
removeAttr(el, ATTR_ARIA_CONTROLS)
212+
removeStyle(el, STYLE_OVERFLOW_ANCHOR)
204213
}
205214

206215
// Add/Update our click listener(s)
@@ -248,11 +257,12 @@ export const VBToggle = {
248257
resetProp(el, BV_TOGGLE_CLICK_HANDLER)
249258
resetProp(el, BV_TOGGLE_STATE)
250259
resetProp(el, BV_TOGGLE_TARGETS)
251-
// Reset classes/attrs
260+
// Reset classes/attrs/styles
252261
removeClass(el, CLASS_BV_TOGGLE_COLLAPSED)
253262
removeClass(el, CLASS_BV_TOGGLE_NOT_COLLAPSED)
254263
removeAttr(el, ATTR_ARIA_EXPANDED)
255264
removeAttr(el, ATTR_ARIA_CONTROLS)
256265
removeAttr(el, ATTR_ROLE)
266+
removeStyle(el, STYLE_OVERFLOW_ANCHOR)
257267
}
258268
}

src/utils/bv-collapse.js

+10-10
Original file line numberDiff line numberDiff line change
@@ -7,32 +7,32 @@
77
// in-place after the transition completes
88
import Vue from './vue'
99
import { mergeData } from 'vue-functional-data-merge'
10-
import { getBCR, reflow, requestAF } from './dom'
10+
import { getBCR, reflow, removeStyle, requestAF, setStyle } from './dom'
1111

1212
// Transition event handler helpers
1313
const onEnter = el => {
14-
el.style.height = 0
15-
// Animaton frame delay needed for `appear` to work
14+
setStyle(el, 'height', 0)
15+
// In a `requestAF()` for `appear` to work
1616
requestAF(() => {
1717
reflow(el)
18-
el.style.height = `${el.scrollHeight}px`
18+
setStyle(el, 'height', `${el.scrollHeight}px`)
1919
})
2020
}
2121

2222
const onAfterEnter = el => {
23-
el.style.height = null
23+
removeStyle(el, 'height')
2424
}
2525

2626
const onLeave = el => {
27-
el.style.height = 'auto'
28-
el.style.display = 'block'
29-
el.style.height = `${getBCR(el).height}px`
27+
setStyle(el, 'height', 'auto')
28+
setStyle(el, 'display', 'block')
29+
setStyle(el, 'height', `${getBCR(el).height}px`)
3030
reflow(el)
31-
el.style.height = 0
31+
setStyle(el, 'height', 0)
3232
}
3333

3434
const onAfterLeave = el => {
35-
el.style.height = null
35+
removeStyle(el, 'height')
3636
}
3737

3838
// Default transition props

src/utils/dom.js

+21-3
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export const isVisible = el => {
8888
// are not a direct descendant of document.body
8989
return false
9090
}
91-
if (el.style.display === 'none') {
91+
if (getStyle(el, 'display') === 'none') {
9292
// We do this check to help with vue-test-utils when using v-show
9393
/* istanbul ignore next */
9494
return false
@@ -174,9 +174,9 @@ export const hasClass = (el, className) => {
174174
}
175175

176176
// Set an attribute on an element
177-
export const setAttr = (el, attr, val) => {
177+
export const setAttr = (el, attr, value) => {
178178
if (attr && isElement(el)) {
179-
el.setAttribute(attr, val)
179+
el.setAttribute(attr, value)
180180
}
181181
}
182182

@@ -195,6 +195,24 @@ export const getAttr = (el, attr) => (attr && isElement(el) ? el.getAttribute(at
195195
// Returns `true` or `false`, or `null` if element not found
196196
export const hasAttr = (el, attr) => (attr && isElement(el) ? el.hasAttribute(attr) : null)
197197

198+
// Set an style property on an element
199+
export const setStyle = (el, prop, value) => {
200+
if (prop && isElement(el)) {
201+
el.style[prop] = value
202+
}
203+
}
204+
205+
// Remove an style property from an element
206+
export const removeStyle = (el, prop) => {
207+
if (prop && isElement(el)) {
208+
el.style[prop] = ''
209+
}
210+
}
211+
212+
// Get an style property value from an element
213+
// Returns `null` if not found
214+
export const getStyle = (el, prop) => (prop && isElement(el) ? el.style[prop] || null : null)
215+
198216
// Return the Bounding Client Rect of an element
199217
// Returns `null` if not an element
200218
/* istanbul ignore next: getBoundingClientRect() doesn't work in JSDOM */

src/utils/dom.spec.js

+26-8
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
import { mount } from '@vue/test-utils'
22
import { createContainer } from '../../tests/utils'
33
import {
4-
isElement,
5-
isDisabled,
6-
contains,
74
closest,
5+
contains,
6+
getAttr,
7+
getStyle,
8+
hasAttr,
9+
hasClass,
10+
isDisabled,
11+
isElement,
812
matches,
913
select,
10-
selectAll,
11-
hasAttr,
12-
getAttr,
13-
hasClass
14+
selectAll
1415
} from './dom'
1516

1617
const template = `
1718
<div id="a" class="foo">
1819
<div class="bar">
19-
<span class="barspan foobar"></span>
20+
<span class="barspan foobar" style="color: red;"></span>
2021
</div>
2122
<div class="baz">
2223
<button id="button1" aria-label="label">btn 1</button>
@@ -197,6 +198,23 @@ describe('utils/dom', () => {
197198
wrapper.destroy()
198199
})
199200

201+
it('getStyle() works', async () => {
202+
const wrapper = mount(App, {
203+
attachTo: createContainer()
204+
})
205+
206+
expect(wrapper).toBeDefined()
207+
208+
const $span = wrapper.find('span.barspan')
209+
expect($span).toBeDefined()
210+
expect($span.exists()).toBe(true)
211+
expect(getStyle($span.element, 'color')).toBe('red')
212+
expect(getStyle($span.element, 'width')).toBe(null)
213+
expect(getStyle(null, 'color')).toBe(null)
214+
215+
wrapper.destroy()
216+
})
217+
200218
it('select() works', async () => {
201219
const wrapper = mount(App, {
202220
attachTo: createContainer()

0 commit comments

Comments
 (0)