]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Dialog/Drawer: fix focus-restore scroll and dispose-while-open cleanup (#42544)
authorMark Otto <markd.otto@gmail.com>
Sat, 27 Jun 2026 03:14:40 +0000 (20:14 -0700)
committerGitHub <noreply@github.com>
Sat, 27 Jun 2026 03:14:40 +0000 (20:14 -0700)
* 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 <dialog> 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

.bundlewatch.config.json
js/src/dialog-base.js
js/src/dialog.js
js/src/drawer.js
js/tests/unit/dialog.spec.js
js/tests/unit/drawer.spec.js

index 2c89f7ea47a8b2429c943ca75c8568f22dc41373..eb14781be686c01ad51390a71a31105bcce8181b 100644 (file)
@@ -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",
index 29c66278311b31c2590b5985bf3192f93c24f4af..0aaf5ff8e4535dff82da0c341f044d13499e3a43 100644 (file)
@@ -117,6 +117,17 @@ class DialogBase extends BaseComponent {
     }, this._element, this._isAnimated())
   }
 
+  dispose() {
+    // If disposed while still open, close the native <dialog> 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() {
index c87c81445593aa7a1c69c0bbc5209642e73d8685..2542f242f7886dd8254e9164f14984cd14f57c83 100644 (file)
@@ -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 })
       }
     })
   })
index ce71ad0f8cb0a26932a1afcbbb3a0d9f1c4eb8a6..3c7ef67aec9f8d8e16e506b73ede7b5217e6d1b3 100644 (file)
@@ -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 })
     }
   })
 
index c763abe5d25ce14ac1ca5d8854fb94395df2e557..5e6c40773089c61b01c18df0229c9b193049c6a8 100644 (file)
@@ -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 = '<dialog class="dialog" id="exampleDialog"></dialog>'
+
+        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)
         }
index a57cbeb1ab830ae1ece451f1554fe88d7c7c0afc..3cc31cb030108555fb988863c133bc843f7f80b5 100644 (file)
@@ -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 = '<dialog class="drawer"></dialog>'
+
+        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)
         })