From: Mark Otto Date: Sat, 27 Jun 2026 03:15:07 +0000 (-0700) Subject: Dialog/Drawer: lock scroll on the root element to prevent layout shift (#42545) X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=4d08db13bf83bd3454497b0a5160d48775ea5cb9;p=thirdparty%2Fbootstrap.git 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 element instead of , 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 --- diff --git a/js/src/dialog-base.js b/js/src/dialog-base.js index 0aaf5ff8e4..3601611545 100644 --- a/js/src/dialog-base.js +++ b/js/src/dialog-base.js @@ -170,7 +170,11 @@ class DialogBase extends BaseComponent { } if (preventBodyScroll) { - document.body.classList.add(CLASS_NAME_OPEN) + // Lock scroll on the root element (not ) so it lands on the same + // element that carries `scrollbar-gutter: stable`. Co-locating them keeps + // the gutter reserved while the scrollbar is hidden, so the page doesn't + // shift (and the ::backdrop covers the gutter instead of leaving a strip). + document.documentElement.classList.add(CLASS_NAME_OPEN) } } @@ -192,15 +196,15 @@ class DialogBase extends BaseComponent { } } - // Closes the native and tears down body-scroll prevention. + // Closes the native and tears down scroll prevention. // Safe to call multiple times — close() is a no-op on a closed dialog. _closeAndCleanup() { this._element.close() this._openedAsModal = false - // Only restore body scroll if no other modal dialogs are open + // Only restore scroll if no other modal dialogs are open if (!document.querySelector('dialog[open]:modal')) { - document.body.classList.remove(CLASS_NAME_OPEN) + document.documentElement.classList.remove(CLASS_NAME_OPEN) } } diff --git a/js/tests/unit/dialog.spec.js b/js/tests/unit/dialog.spec.js index 5e6c407730..223c70f84e 100644 --- a/js/tests/unit/dialog.spec.js +++ b/js/tests/unit/dialog.spec.js @@ -15,7 +15,7 @@ describe('Dialog', () => { afterEach(() => { clearFixture() clearBodyAndDocument() - document.body.classList.remove('dialog-open') + document.documentElement.classList.remove('dialog-open') for (const dialog of document.querySelectorAll('dialog[open]')) { dialog.close() @@ -94,7 +94,7 @@ describe('Dialog', () => { dialogEl.addEventListener('shown.bs.dialog', () => { expect(dialogEl.open).toBeTrue() - expect(document.body.classList.contains('dialog-open')).toBeTrue() + expect(document.documentElement.classList.contains('dialog-open')).toBeTrue() resolve() }) @@ -251,7 +251,7 @@ describe('Dialog', () => { dialogEl.addEventListener('hidden.bs.dialog', () => { expect(dialogEl.open).toBeFalse() - expect(document.body.classList.contains('dialog-open')).toBeFalse() + expect(document.documentElement.classList.contains('dialog-open')).toBeFalse() resolve() }) @@ -460,8 +460,8 @@ describe('Dialog', () => { dialogEl.addEventListener('shown.bs.dialog', () => { expect(dialogEl.open).toBeTrue() expect(dialogEl.classList.contains('dialog-nonmodal')).toBeTrue() - // Non-modal dialogs should not add dialog-open to body - expect(document.body.classList.contains('dialog-open')).toBeFalse() + // Non-modal dialogs should not add dialog-open to the root element + expect(document.documentElement.classList.contains('dialog-open')).toBeFalse() resolve() }) @@ -1058,7 +1058,7 @@ describe('Dialog', () => { }) describe('stacked modals', () => { - it('should keep dialog-open on body when closing one of two open modal dialogs', () => { + it('should keep dialog-open on the root element when closing one of two open modal dialogs', () => { return new Promise(resolve => { fixtureEl.innerHTML = [ '', @@ -1075,18 +1075,18 @@ describe('Dialog', () => { }) dialog2El.addEventListener('shown.bs.dialog', () => { - expect(document.body.classList.contains('dialog-open')).toBeTrue() + expect(document.documentElement.classList.contains('dialog-open')).toBeTrue() dialog1.hide() }) dialog1El.addEventListener('hidden.bs.dialog', () => { expect(dialog2El.open).toBeTrue() - expect(document.body.classList.contains('dialog-open')).toBeTrue() + expect(document.documentElement.classList.contains('dialog-open')).toBeTrue() dialog2.hide() }) dialog2El.addEventListener('hidden.bs.dialog', () => { - expect(document.body.classList.contains('dialog-open')).toBeFalse() + expect(document.documentElement.classList.contains('dialog-open')).toBeFalse() resolve() }) diff --git a/js/tests/unit/drawer.spec.js b/js/tests/unit/drawer.spec.js index 3cc31cb030..f87c10e54a 100644 --- a/js/tests/unit/drawer.spec.js +++ b/js/tests/unit/drawer.spec.js @@ -16,7 +16,7 @@ describe('Drawer', () => { afterEach(() => { clearFixture() - document.body.classList.remove('dialog-open') + document.documentElement.classList.remove('dialog-open') clearBodyAndDocument() for (const dialog of document.querySelectorAll('dialog[open]')) { @@ -255,7 +255,7 @@ describe('Drawer', () => { }) describe('options', () => { - it('if scroll is enabled, should not add dialog-open class to body', () => { + it('if scroll is enabled, should not add dialog-open class to the root element', () => { return new Promise(resolve => { fixtureEl.innerHTML = '' @@ -263,7 +263,7 @@ describe('Drawer', () => { const drawer = new Drawer(drawerEl, { scroll: true, backdrop: false }) drawerEl.addEventListener('shown.bs.drawer', () => { - expect(document.body.classList.contains('dialog-open')).toBeFalse() + expect(document.documentElement.classList.contains('dialog-open')).toBeFalse() drawer.hide() }) drawerEl.addEventListener('hidden.bs.drawer', () => { @@ -273,7 +273,7 @@ describe('Drawer', () => { }) }) - it('if scroll is disabled, should add dialog-open class to body', () => { + it('if scroll is disabled, should add dialog-open class to the root element', () => { return new Promise(resolve => { fixtureEl.innerHTML = '' @@ -281,11 +281,11 @@ describe('Drawer', () => { const drawer = new Drawer(drawerEl, { scroll: false }) drawerEl.addEventListener('shown.bs.drawer', () => { - expect(document.body.classList.contains('dialog-open')).toBeTrue() + expect(document.documentElement.classList.contains('dialog-open')).toBeTrue() drawer.hide() }) drawerEl.addEventListener('hidden.bs.drawer', () => { - expect(document.body.classList.contains('dialog-open')).toBeFalse() + expect(document.documentElement.classList.contains('dialog-open')).toBeFalse() resolve() }) drawer.show() diff --git a/scss/_dialog.scss b/scss/_dialog.scss index 74f9000768..ee2b36e400 100644 --- a/scss/_dialog.scss +++ b/scss/_dialog.scss @@ -59,8 +59,11 @@ $dialog-sizes: defaults( // scss-docs-end dialog-sizes @layer components { - // Prevent body scroll when dialog is open - .dialog-open { + // Prevent page scroll when a dialog is open. Applied to the root element so + // `overflow: hidden` sits on the same element as `scrollbar-gutter: stable` + // (see _root.scss): the gutter stays reserved while the scrollbar is hidden, + // so the page doesn't shift when a dialog opens. + :root.dialog-open { overflow: hidden; } diff --git a/site/src/content/docs/guides/migration.mdx b/site/src/content/docs/guides/migration.mdx index 8c7bad26cb..48e9b70193 100644 --- a/site/src/content/docs/guides/migration.mdx +++ b/site/src/content/docs/guides/migration.mdx @@ -177,7 +177,7 @@ Bootstrap 6 is a major release with many breaking changes to modernize our codeb - Data key: `bs.modal` → `bs.dialog` (affects `Dialog.getInstance()` and `Dialog.getOrCreateInstance()`). - CSS variables: `--modal-*` → `--dialog-*`. - 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. - - Body scroll prevention: `.modal-open` on `` → `.dialog-open` on the `` element. + - Scroll prevention: `.modal-open` on `` → `.dialog-open` on the root (``) element, so it pairs with `scrollbar-gutter: stable` and the page doesn't shift when a dialog opens. - 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`. - Non-modal support: Set `modal: false` or `data-bs-modal="false"` for non-modal dialogs. - Dialog swapping: Triggers inside an open dialog can open a new dialog and close the current one automatically.