]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Menu/Popover: setContent show gate + forms-in-menu auto-close (#42551)
authorMark Otto <markd.otto@gmail.com>
Sat, 27 Jun 2026 03:27:24 +0000 (20:27 -0700)
committerGitHub <noreply@github.com>
Sat, 27 Jun 2026 03:27:24 +0000 (20:27 -0700)
* Popover/Tooltip: allow showing content set only via setContent()

show() gated on _isWithContent(), which read config title/content and
ignored content supplied through setContent(). A popover whose content
was set only via setContent() could therefore never be shown. Count
setContent() content in the gate via a shared _hasNewContent() helper.
Fixes #40525.

* Menu: keep open when clicking inside a form within the menu

clearMenus only skipped auto-close for input/select/option/textarea/form
tag names, so clicking a form's label/button/etc. inside a menu closed it
— breaking the documented forms-in-menu example. Skip auto-close for any
click inside a form contained in the menu. Fixes #41803.

* Bump bundlewatch size thresholds

.bundlewatch.config.json
js/src/menu.js
js/src/popover.js
js/src/tooltip.js
js/tests/unit/menu.spec.js
js/tests/unit/popover.spec.js

index 5835ee13835788af36d6100b2aeae02e0e3f965d..8986f6b95373ca0aa52b31a22256ada237b28353 100644 (file)
@@ -42,7 +42,7 @@
     },
     {
       "path": "./dist/js/bootstrap.js",
-      "maxSize": "54.5 kB"
+      "maxSize": "55.0 kB"
     },
     {
       "path": "./dist/js/bootstrap.min.js",
index 69c2f9e00e9bfd926e1299952d9d4721a44f671c..0dd7efd396f5f93458f46dd7f448f81335316337 100644 (file)
@@ -901,7 +901,11 @@ class Menu extends BaseComponent {
         continue
       }
 
-      if (instance._menu.contains(event.target) && ((event.type === 'keyup' && event.key === TAB_KEY) || /input|select|option|textarea|form/i.test(event.target.tagName))) {
+      // Don't auto-close when interacting with a form inside the menu — clicks
+      // on a form's labels, buttons, etc. (not just inputs) should keep it open.
+      const formAncestor = event.target.closest?.('form')
+      const isInsideMenuForm = Boolean(formAncestor) && instance._menu.contains(formAncestor)
+      if (instance._menu.contains(event.target) && ((event.type === 'keyup' && event.key === TAB_KEY) || /input|select|option|textarea|form/i.test(event.target.tagName) || isInsideMenuForm)) {
         continue
       }
 
index 807e6bdb48e9f363d1c3b5f1ed3b8eef19d5c782..176f69378a5e7da78f1724ad74a6b0567e5fa152 100644 (file)
@@ -60,7 +60,7 @@ class Popover extends Tooltip {
 
   // Overrides
   _isWithContent() {
-    return this._getTitle() || this._getContent()
+    return Boolean(this._getTitle() || this._getContent()) || this._hasNewContent()
   }
 
   // Private
index f664882774c194f2cb2cf632d90cc9035fcecf81..d69faf000375059f0e1f99437e72d79334b6e550 100644 (file)
@@ -326,7 +326,15 @@ class Tooltip extends BaseComponent {
 
   // Protected
   _isWithContent() {
-    return Boolean(this._getTitle())
+    return Boolean(this._getTitle()) || this._hasNewContent()
+  }
+
+  // Content supplied via setContent() (a `{ selector: content }` map) overrides
+  // the configured title/content when rendering, so it should also satisfy the
+  // show() gate — otherwise a tip whose content is only set via setContent()
+  // can never be shown.
+  _hasNewContent() {
+    return Boolean(this._newContent) && Object.values(this._newContent).some(Boolean)
   }
 
   _getTipElement() {
index 9a02719f1ca9b117fde115dabbc58125c52f9dbf..74edcaf20d6531dc66f07dc83377eb53fc2ac673 100644 (file)
@@ -1962,6 +1962,36 @@ describe('Menu', () => {
       })
     })
 
+    it('should not close the menu if the user clicks a label inside a form within the menu', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div>',
+          '  <button class="btn" data-bs-toggle="menu">Menu</button>',
+          '  <div class="menu">',
+          '    <form>',
+          '      <label for="menu-email">Email</label>',
+          '      <input type="text" id="menu-email">',
+          '    </form>',
+          '  </div>',
+          '</div>'
+        ].join('')
+
+        const triggerMenu = fixtureEl.querySelector('[data-bs-toggle="menu"]')
+        const label = fixtureEl.querySelector('label')
+
+        triggerMenu.addEventListener('shown.bs.menu', () => {
+          expect(triggerMenu).toHaveClass('show')
+          // clearMenus runs synchronously while this bubbling click reaches the
+          // document, so assert right after dispatch returns.
+          label.dispatchEvent(createEvent('click', { bubbles: true }))
+          expect(triggerMenu).toHaveClass('show')
+          resolve()
+        })
+
+        triggerMenu.click()
+      })
+    })
+
     it('should close the menu if the user clicks on a text field that is not contained within menu', () => {
       return new Promise(resolve => {
         fixtureEl.innerHTML = [
index c25f5af9c378533880058e4e29b7663d12788c30..87bd02003916ec0c52a46a104da19fa9f8659423 100644 (file)
@@ -193,6 +193,27 @@ describe('Popover', () => {
       })
     })
 
+    it('should show a popover whose content was set only via setContent()', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = '<a href="#">Nice link</a>'
+
+        const popoverEl = fixtureEl.querySelector('a')
+        const popover = new Popover(popoverEl, {})
+
+        popover.setContent({ '.popover-header': 'Late header', '.popover-body': 'Late content' })
+
+        popoverEl.addEventListener('shown.bs.popover', () => {
+          const popoverDisplayed = document.querySelector('.popover')
+
+          expect(popoverDisplayed).not.toBeNull()
+          expect(popoverDisplayed.querySelector('.popover-body').textContent).toEqual('Late content')
+          resolve()
+        })
+
+        popover.show()
+      })
+    })
+
     it('should show a popover with just title without having body', () => {
       return new Promise(resolve => {
         fixtureEl.innerHTML = '<a href="#">Nice link</a>'