From: Mark Otto Date: Sat, 27 Jun 2026 03:14:40 +0000 (-0700) Subject: Dialog/Drawer: fix focus-restore scroll and dispose-while-open cleanup (#42544) X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=aa273699314cabde5b57f784dfa54c2f11c2610d;p=thirdparty%2Fbootstrap.git Dialog/Drawer: fix focus-restore scroll and dispose-while-open cleanup (#42544) * Dialog/Drawer: don't scroll the page when restoring focus on close Pass { preventScroll: true } when returning focus to the trigger after close, so the page no longer jumps to the trigger (or to the top when scroll-padding-top is set). Fixes #38070, #41615, #35391. * Dialog/Drawer: restore body scroll when disposed while open dispose() now closes the native and removes the dialog-open body class if the instance is torn down while still open (e.g. an SPA route change), instead of leaving overflow: hidden stuck on the body. Fixes #35934, #39910. * Bump bundlewatch size thresholds --- diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 2c89f7ea47..eb14781be6 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -34,7 +34,7 @@ }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "82.5 kB" + "maxSize": "82.75 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", @@ -42,7 +42,7 @@ }, { "path": "./dist/js/bootstrap.js", - "maxSize": "53.75 kB" + "maxSize": "54.0 kB" }, { "path": "./dist/js/bootstrap.min.js", diff --git a/js/src/dialog-base.js b/js/src/dialog-base.js index 29c6627831..0aaf5ff8e4 100644 --- a/js/src/dialog-base.js +++ b/js/src/dialog-base.js @@ -117,6 +117,17 @@ class DialogBase extends BaseComponent { }, this._element, this._isAnimated()) } + dispose() { + // If disposed while still open, close the native and restore body + // scroll. Otherwise `dialog-open` (overflow: hidden) would stay stuck on the + // body — e.g. when an SPA tears the component down mid-navigation. + if (this._element.open) { + this._closeAndCleanup() + } + + super.dispose() + } + // Protected — hooks for subclasses to override _getShowOptions() { diff --git a/js/src/dialog.js b/js/src/dialog.js index c87c814455..2542f242f7 100644 --- a/js/src/dialog.js +++ b/js/src/dialog.js @@ -119,7 +119,7 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( EventHandler.one(target, EVENT_HIDDEN, () => { if (isVisible(this)) { - this.focus() + this.focus({ preventScroll: true }) } }) }) diff --git a/js/src/drawer.js b/js/src/drawer.js index ce71ad0f8c..3c7ef67aec 100644 --- a/js/src/drawer.js +++ b/js/src/drawer.js @@ -149,7 +149,7 @@ EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function ( EventHandler.one(target, EVENT_HIDDEN, () => { if (isVisible(this)) { - this.focus() + this.focus({ preventScroll: true }) } }) diff --git a/js/tests/unit/dialog.spec.js b/js/tests/unit/dialog.spec.js index c763abe5d2..5e6c407730 100644 --- a/js/tests/unit/dialog.spec.js +++ b/js/tests/unit/dialog.spec.js @@ -713,6 +713,28 @@ describe('Dialog', () => { expect(Dialog.getInstance(dialogEl)).toBeNull() expect(spyOff).toHaveBeenCalled() }) + + it('should close the dialog and restore body scroll when disposed while open', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const dialogEl = fixtureEl.querySelector('.dialog') + const dialog = new Dialog(dialogEl) + + dialogEl.addEventListener('shown.bs.dialog', () => { + expect(dialogEl.open).toBeTrue() + expect(document.body.classList.contains('dialog-open')).toBeTrue() + + dialog.dispose() + + expect(dialogEl.open).toBeFalse() + expect(document.body.classList.contains('dialog-open')).toBeFalse() + resolve() + }) + + dialog.show() + }) + }) }) describe('data-api', () => { @@ -803,7 +825,7 @@ describe('Dialog', () => { const hideListener = () => { setTimeout(() => { - expect(spy).toHaveBeenCalled() + expect(spy).toHaveBeenCalledWith({ preventScroll: true }) resolve() }, 20) } diff --git a/js/tests/unit/drawer.spec.js b/js/tests/unit/drawer.spec.js index a57cbeb1ab..3cc31cb030 100644 --- a/js/tests/unit/drawer.spec.js +++ b/js/tests/unit/drawer.spec.js @@ -570,6 +570,28 @@ describe('Drawer', () => { expect(Drawer.getInstance(drawerEl)).toBeNull() expect(spyOff).toHaveBeenCalled() }) + + it('should close the drawer and restore body scroll when disposed while open', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = '' + + const drawerEl = fixtureEl.querySelector('dialog') + const drawer = new Drawer(drawerEl) + + drawerEl.addEventListener('shown.bs.drawer', () => { + expect(drawerEl.open).toBeTrue() + expect(document.body.classList.contains('dialog-open')).toBeTrue() + + drawer.dispose() + + expect(drawerEl.open).toBeFalse() + expect(document.body.classList.contains('dialog-open')).toBeFalse() + resolve() + }) + + drawer.show() + }) + }) }) describe('data-api', () => { @@ -649,7 +671,7 @@ describe('Drawer', () => { }) drawerEl.addEventListener('hidden.bs.drawer', () => { setTimeout(() => { - expect(spy).toHaveBeenCalled() + expect(spy).toHaveBeenCalledWith({ preventScroll: true }) resolve() }, 5) })