}
if (preventBodyScroll) {
- document.body.classList.add(CLASS_NAME_OPEN)
+ // Lock scroll on the root element (not <body>) 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)
}
}
}
}
- // Closes the native <dialog> and tears down body-scroll prevention.
+ // Closes the native <dialog> 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)
}
}
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()
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()
})
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()
})
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()
})
})
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 = [
'<dialog id="dialog1" class="dialog"></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()
})
afterEach(() => {
clearFixture()
- document.body.classList.remove('dialog-open')
+ document.documentElement.classList.remove('dialog-open')
clearBodyAndDocument()
for (const dialog of document.querySelectorAll('dialog[open]')) {
})
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 = '<dialog class="drawer"></dialog>'
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', () => {
})
})
- 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 = '<dialog class="drawer"></dialog>'
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()
// 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;
}
- 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 `<body>` → `.dialog-open` on the `<body>` element.
+ - Scroll prevention: `.modal-open` on `<body>` → `.dialog-open` on the root (`<html>`) 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.