Skip to content

Commit 4d08db1

Browse files
authored
Dialog/Drawer: lock scroll on the root element to prevent layout shift (#42545)
* Dialog/Drawer: lock scroll on the root element to prevent layout shift Apply the dialog-open scroll-lock (overflow: hidden) to the root <html> element instead of <body>, so it sits on the same element as scrollbar-gutter: stable. Co-locating them keeps the gutter reserved while the scrollbar is hidden, so the page no longer shifts (and the ::backdrop covers the gutter instead of leaving a white strip) when a dialog or drawer opens. Fixes #39221, #39972, #40908, #40659. * Bump bundlewatch size thresholds
1 parent aa27369 commit 4d08db1

5 files changed

Lines changed: 29 additions & 22 deletions

File tree

js/src/dialog-base.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,11 @@ class DialogBase extends BaseComponent {
170170
}
171171

172172
if (preventBodyScroll) {
173-
document.body.classList.add(CLASS_NAME_OPEN)
173+
// Lock scroll on the root element (not <body>) so it lands on the same
174+
// element that carries `scrollbar-gutter: stable`. Co-locating them keeps
175+
// the gutter reserved while the scrollbar is hidden, so the page doesn't
176+
// shift (and the ::backdrop covers the gutter instead of leaving a strip).
177+
document.documentElement.classList.add(CLASS_NAME_OPEN)
174178
}
175179
}
176180

@@ -192,15 +196,15 @@ class DialogBase extends BaseComponent {
192196
}
193197
}
194198

195-
// Closes the native <dialog> and tears down body-scroll prevention.
199+
// Closes the native <dialog> and tears down scroll prevention.
196200
// Safe to call multiple times — close() is a no-op on a closed dialog.
197201
_closeAndCleanup() {
198202
this._element.close()
199203
this._openedAsModal = false
200204

201-
// Only restore body scroll if no other modal dialogs are open
205+
// Only restore scroll if no other modal dialogs are open
202206
if (!document.querySelector('dialog[open]:modal')) {
203-
document.body.classList.remove(CLASS_NAME_OPEN)
207+
document.documentElement.classList.remove(CLASS_NAME_OPEN)
204208
}
205209
}
206210

js/tests/unit/dialog.spec.js

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ describe('Dialog', () => {
1515
afterEach(() => {
1616
clearFixture()
1717
clearBodyAndDocument()
18-
document.body.classList.remove('dialog-open')
18+
document.documentElement.classList.remove('dialog-open')
1919

2020
for (const dialog of document.querySelectorAll('dialog[open]')) {
2121
dialog.close()
@@ -94,7 +94,7 @@ describe('Dialog', () => {
9494

9595
dialogEl.addEventListener('shown.bs.dialog', () => {
9696
expect(dialogEl.open).toBeTrue()
97-
expect(document.body.classList.contains('dialog-open')).toBeTrue()
97+
expect(document.documentElement.classList.contains('dialog-open')).toBeTrue()
9898
resolve()
9999
})
100100

@@ -251,7 +251,7 @@ describe('Dialog', () => {
251251

252252
dialogEl.addEventListener('hidden.bs.dialog', () => {
253253
expect(dialogEl.open).toBeFalse()
254-
expect(document.body.classList.contains('dialog-open')).toBeFalse()
254+
expect(document.documentElement.classList.contains('dialog-open')).toBeFalse()
255255
resolve()
256256
})
257257

@@ -460,8 +460,8 @@ describe('Dialog', () => {
460460
dialogEl.addEventListener('shown.bs.dialog', () => {
461461
expect(dialogEl.open).toBeTrue()
462462
expect(dialogEl.classList.contains('dialog-nonmodal')).toBeTrue()
463-
// Non-modal dialogs should not add dialog-open to body
464-
expect(document.body.classList.contains('dialog-open')).toBeFalse()
463+
// Non-modal dialogs should not add dialog-open to the root element
464+
expect(document.documentElement.classList.contains('dialog-open')).toBeFalse()
465465
resolve()
466466
})
467467

@@ -1058,7 +1058,7 @@ describe('Dialog', () => {
10581058
})
10591059

10601060
describe('stacked modals', () => {
1061-
it('should keep dialog-open on body when closing one of two open modal dialogs', () => {
1061+
it('should keep dialog-open on the root element when closing one of two open modal dialogs', () => {
10621062
return new Promise(resolve => {
10631063
fixtureEl.innerHTML = [
10641064
'<dialog id="dialog1" class="dialog"></dialog>',
@@ -1075,18 +1075,18 @@ describe('Dialog', () => {
10751075
})
10761076

10771077
dialog2El.addEventListener('shown.bs.dialog', () => {
1078-
expect(document.body.classList.contains('dialog-open')).toBeTrue()
1078+
expect(document.documentElement.classList.contains('dialog-open')).toBeTrue()
10791079
dialog1.hide()
10801080
})
10811081

10821082
dialog1El.addEventListener('hidden.bs.dialog', () => {
10831083
expect(dialog2El.open).toBeTrue()
1084-
expect(document.body.classList.contains('dialog-open')).toBeTrue()
1084+
expect(document.documentElement.classList.contains('dialog-open')).toBeTrue()
10851085
dialog2.hide()
10861086
})
10871087

10881088
dialog2El.addEventListener('hidden.bs.dialog', () => {
1089-
expect(document.body.classList.contains('dialog-open')).toBeFalse()
1089+
expect(document.documentElement.classList.contains('dialog-open')).toBeFalse()
10901090
resolve()
10911091
})
10921092

js/tests/unit/drawer.spec.js

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ describe('Drawer', () => {
1616

1717
afterEach(() => {
1818
clearFixture()
19-
document.body.classList.remove('dialog-open')
19+
document.documentElement.classList.remove('dialog-open')
2020
clearBodyAndDocument()
2121

2222
for (const dialog of document.querySelectorAll('dialog[open]')) {
@@ -255,15 +255,15 @@ describe('Drawer', () => {
255255
})
256256

257257
describe('options', () => {
258-
it('if scroll is enabled, should not add dialog-open class to body', () => {
258+
it('if scroll is enabled, should not add dialog-open class to the root element', () => {
259259
return new Promise(resolve => {
260260
fixtureEl.innerHTML = '<dialog class="drawer"></dialog>'
261261

262262
const drawerEl = fixtureEl.querySelector('.drawer')
263263
const drawer = new Drawer(drawerEl, { scroll: true, backdrop: false })
264264

265265
drawerEl.addEventListener('shown.bs.drawer', () => {
266-
expect(document.body.classList.contains('dialog-open')).toBeFalse()
266+
expect(document.documentElement.classList.contains('dialog-open')).toBeFalse()
267267
drawer.hide()
268268
})
269269
drawerEl.addEventListener('hidden.bs.drawer', () => {
@@ -273,19 +273,19 @@ describe('Drawer', () => {
273273
})
274274
})
275275

276-
it('if scroll is disabled, should add dialog-open class to body', () => {
276+
it('if scroll is disabled, should add dialog-open class to the root element', () => {
277277
return new Promise(resolve => {
278278
fixtureEl.innerHTML = '<dialog class="drawer"></dialog>'
279279

280280
const drawerEl = fixtureEl.querySelector('.drawer')
281281
const drawer = new Drawer(drawerEl, { scroll: false })
282282

283283
drawerEl.addEventListener('shown.bs.drawer', () => {
284-
expect(document.body.classList.contains('dialog-open')).toBeTrue()
284+
expect(document.documentElement.classList.contains('dialog-open')).toBeTrue()
285285
drawer.hide()
286286
})
287287
drawerEl.addEventListener('hidden.bs.drawer', () => {
288-
expect(document.body.classList.contains('dialog-open')).toBeFalse()
288+
expect(document.documentElement.classList.contains('dialog-open')).toBeFalse()
289289
resolve()
290290
})
291291
drawer.show()

scss/_dialog.scss

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,11 @@ $dialog-sizes: defaults(
5959
// scss-docs-end dialog-sizes
6060

6161
@layer components {
62-
// Prevent body scroll when dialog is open
63-
.dialog-open {
62+
// Prevent page scroll when a dialog is open. Applied to the root element so
63+
// `overflow: hidden` sits on the same element as `scrollbar-gutter: stable`
64+
// (see _root.scss): the gutter stays reserved while the scrollbar is hidden,
65+
// so the page doesn't shift when a dialog opens.
66+
:root.dialog-open {
6467
overflow: hidden;
6568
}
6669

site/src/content/docs/guides/migration.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ Bootstrap 6 is a major release with many breaking changes to modernize our codeb
177177
- Data key: `bs.modal` &rarr; `bs.dialog` (affects `Dialog.getInstance()` and `Dialog.getOrCreateInstance()`).
178178
- CSS variables: `--modal-*` &rarr; `--dialog-*`.
179179
- Backdrop: The `.modal-backdrop` DOM element and the legacy `util/backdrop` helper are gone — Dialog uses the native `::backdrop` pseudo-element with `backdrop-filter: blur()` support.
180-
- Body scroll prevention: `.modal-open` on `<body>` &rarr; `.dialog-open` on the `<body>` element.
180+
- Scroll prevention: `.modal-open` on `<body>` &rarr; `.dialog-open` on the root (`<html>`) element, so it pairs with `scrollbar-gutter: stable` and the page doesn't shift when a dialog opens.
181181
- New variant classes: `.dialog-slide-up`, `.dialog-slide-down` (slide animations), `.dialog-instant` (no animation), `.dialog-static` (static backdrop bounce), `.dialog-nonmodal` (non-modal positioning), `.dialog-scrollable`.
182182
- Non-modal support: Set `modal: false` or `data-bs-modal="false"` for non-modal dialogs.
183183
- Dialog swapping: Triggers inside an open dialog can open a new dialog and close the current one automatically.

0 commit comments

Comments
 (0)