From: Mark Otto Date: Sat, 27 Jun 2026 03:27:24 +0000 (-0700) Subject: Menu/Popover: setContent show gate + forms-in-menu auto-close (#42551) X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=f28f6cc37b92da79d7e07dd9582f7b25d0ee5531;p=thirdparty%2Fbootstrap.git Menu/Popover: setContent show gate + forms-in-menu auto-close (#42551) * 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 --- diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 5835ee1383..8986f6b953 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -42,7 +42,7 @@ }, { "path": "./dist/js/bootstrap.js", - "maxSize": "54.5 kB" + "maxSize": "55.0 kB" }, { "path": "./dist/js/bootstrap.min.js", diff --git a/js/src/menu.js b/js/src/menu.js index 69c2f9e00e..0dd7efd396 100644 --- a/js/src/menu.js +++ b/js/src/menu.js @@ -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 } diff --git a/js/src/popover.js b/js/src/popover.js index 807e6bdb48..176f69378a 100644 --- a/js/src/popover.js +++ b/js/src/popover.js @@ -60,7 +60,7 @@ class Popover extends Tooltip { // Overrides _isWithContent() { - return this._getTitle() || this._getContent() + return Boolean(this._getTitle() || this._getContent()) || this._hasNewContent() } // Private diff --git a/js/src/tooltip.js b/js/src/tooltip.js index f664882774..d69faf0003 100644 --- a/js/src/tooltip.js +++ b/js/src/tooltip.js @@ -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() { diff --git a/js/tests/unit/menu.spec.js b/js/tests/unit/menu.spec.js index 9a02719f1c..74edcaf20d 100644 --- a/js/tests/unit/menu.spec.js +++ b/js/tests/unit/menu.spec.js @@ -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 = [ + '
', + ' ', + ' ', + '
' + ].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 = [ diff --git a/js/tests/unit/popover.spec.js b/js/tests/unit/popover.spec.js index c25f5af9c3..87bd020039 100644 --- a/js/tests/unit/popover.spec.js +++ b/js/tests/unit/popover.spec.js @@ -193,6 +193,27 @@ describe('Popover', () => { }) }) + it('should show a popover whose content was set only via setContent()', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = 'Nice link' + + 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 = 'Nice link'