]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
Menus: support nested toggles and fix submenu position flash (#42533)
authorMark Otto <markd.otto@gmail.com>
Sun, 21 Jun 2026 04:59:54 +0000 (21:59 -0700)
committerGitHub <noreply@github.com>
Sun, 21 Jun 2026 04:59:54 +0000 (21:59 -0700)
* fix that

* update

.bundlewatch.config.json
js/src/dom/selector-engine.js
js/src/menu.js
js/tests/unit/dom/selector-engine.spec.js
js/tests/unit/menu.spec.js

index cd2034c8f8fdda71cac454d0523974f2b747b24a..9da0126d36f945255a4b17db4dc4774bb2d6d76b 100644 (file)
@@ -34,7 +34,7 @@
     },
     {
       "path": "./dist/js/bootstrap.bundle.js",
-      "maxSize": "80.25 kB"
+      "maxSize": "81.0 kB"
     },
     {
       "path": "./dist/js/bootstrap.bundle.min.js",
@@ -42,7 +42,7 @@
     },
     {
       "path": "./dist/js/bootstrap.js",
-      "maxSize": "51.5 kB"
+      "maxSize": "52.25 kB"
     },
     {
       "path": "./dist/js/bootstrap.min.js",
index 9e83d2064ab2859d8ee30236558abef30643f6e3..7c28e178ebe18f82796cadea60c690bb3e884a08 100644 (file)
@@ -57,6 +57,10 @@ const SelectorEngine = {
     return parents
   },
 
+  closest(element, selector) {
+    return Element.prototype.closest.call(element, selector)
+  },
+
   prev(element, selector) {
     let previous = element.previousElementSibling
 
index f70a13457606d510f4d2b98a089136234a7b2e72..28599a8f1af04f5275c19a666226ef048c9a0df8 100644 (file)
@@ -135,14 +135,23 @@ class Menu extends BaseComponent {
     this._floatingCleanup = null
     this._mediaQueryListeners = []
     this._responsivePlacements = null
-    this._parent = this._element.parentNode
-    this._isSubmenu = this._parent.classList?.contains('submenu')
+    this._parent = this._element.parentNode // menu wrapper
     this._openSubmenus = new Map()
     this._submenuCloseTimeouts = new Map()
     this._hoverIntentData = null
 
     this._menu = this._config.menu || this._findMenu()
 
+    // When the menu was discovered from the DOM, refine the wrapper to the closest
+    // ancestor that actually contains it, so the toggle doesn't have to be a direct
+    // sibling of `.menu` (e.g. when wrapped by web components). The wrapper still
+    // receives `.show` and acts as the `reference: 'parent'` positioning anchor.
+    if (!this._config.menu && this._menu) {
+      this._parent = this._findWrapper(this._menu)
+    }
+
+    this._isSubmenu = this._parent.classList?.contains('submenu')
+
     this._menuOriginalParent = this._menu?.parentNode
 
     this._parseResponsivePlacements()
@@ -235,9 +244,21 @@ class Menu extends BaseComponent {
 
   // Private
   _findMenu() {
+    // Fall back to the closest ancestor that contains a menu so the toggle can be
+    // nested deeper than a direct sibling of `.menu`.
+    const wrapper = SelectorEngine.closest(this._element, `:has(${SELECTOR_MENU})`)
     return SelectorEngine.next(this._element, SELECTOR_MENU)[0] ||
       SelectorEngine.prev(this._element, SELECTOR_MENU)[0] ||
-      SelectorEngine.findOne(SELECTOR_MENU, this._parent)
+      SelectorEngine.findOne(SELECTOR_MENU, wrapper || this._parent)
+  }
+
+  _findWrapper(menu) {
+    let wrapper = this._element.parentNode
+    while (wrapper instanceof Element && !wrapper.contains(menu)) {
+      wrapper = wrapper.parentNode
+    }
+
+    return wrapper instanceof Element ? wrapper : this._element.parentNode
   }
 
   _completeHide(relatedTarget) {
@@ -594,6 +615,11 @@ class Menu extends BaseComponent {
     trigger.setAttribute('aria-expanded', 'true')
     trigger.setAttribute('aria-haspopup', 'true')
 
+    // Keep the submenu transparent until Floating UI applies the first position, so
+    // it doesn't flash at its CSS fallback position (top: 0, over the parent menu)
+    // before being moved into place. `opacity` (unlike `visibility`/`display`) keeps
+    // the submenu measurable for flip/shift and focusable for keyboard navigation.
+    submenu.style.opacity = '0'
     submenu.classList.add(CLASS_NAME_SHOW)
     submenuWrapper.classList.add(CLASS_NAME_SHOW)
 
@@ -633,10 +659,12 @@ class Menu extends BaseComponent {
     submenu.classList.remove(CLASS_NAME_SHOW)
     submenuWrapper.classList.remove(CLASS_NAME_SHOW)
 
-    submenu.style.position = ''
-    submenu.style.left = ''
-    submenu.style.top = ''
-    submenu.style.margin = ''
+    // Keep the Floating UI position styles in place while the submenu fades out.
+    // Clearing them here would let the submenu snap back to its CSS fallback
+    // (`top: 0`, over the parent menu) for the duration of the close transition,
+    // causing it to flash over the parent. They get recomputed on the next open
+    // (and the opacity gate in `_openSubmenu` hides any stale position until then).
+    submenu.style.opacity = ''
   }
 
   _closeAllSubmenus() {
@@ -674,6 +702,12 @@ class Menu extends BaseComponent {
     ]
 
     const updatePosition = () => this._applyFloatingPosition(referenceElement, submenu, placement, middleware)
+      .then(finalPlacement => {
+        // Reveal the submenu now that it has been positioned (see `_openSubmenu`);
+        // clearing the inline opacity lets the CSS fade-in transition take over.
+        submenu.style.opacity = ''
+        return finalPlacement
+      })
 
     updatePosition()
     return autoUpdate(referenceElement, submenu, updatePosition)
index f0ec5faf9a0bd4e643a9e8e717ef14c1dedfb16c..2ade2605778739d2265901bef072c6bbedec53b0 100644 (file)
@@ -81,6 +81,33 @@ describe('SelectorEngine', () => {
     })
   })
 
+  describe('closest', () => {
+    it('should return the element itself when it matches the selector', () => {
+      fixtureEl.innerHTML = '<div id="test"><div id="element"></div></div>'
+
+      const element = fixtureEl.querySelector('#element')
+
+      expect(SelectorEngine.closest(element, '#element')).toEqual(element)
+    })
+
+    it('should return the closest ancestor matching the selector', () => {
+      fixtureEl.innerHTML = '<div id="test"><div><div id="element"></div></div></div>'
+
+      const element = fixtureEl.querySelector('#element')
+      const ancestor = fixtureEl.querySelector('#test')
+
+      expect(SelectorEngine.closest(element, '#test')).toEqual(ancestor)
+    })
+
+    it('should return null when no ancestor matches the selector', () => {
+      fixtureEl.innerHTML = '<div><div id="element"></div></div>'
+
+      const element = fixtureEl.querySelector('#element')
+
+      expect(SelectorEngine.closest(element, '.missing')).toBeNull()
+    })
+  })
+
   describe('prev', () => {
     it('should return previous element', () => {
       fixtureEl.innerHTML = '<div class="test"></div><button class="btn"></button>'
index a02005ac5cbf3dfef903342fcc381872f5a245bc..c88d54891ef8c078dec98d2130ff6f693e5ed8d2 100644 (file)
@@ -3014,6 +3014,46 @@ describe('Menu', () => {
       })
     })
 
+    it('should keep the submenu transparent until it is positioned, then reveal it', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div>',
+          '  <button class="btn" data-bs-toggle="menu">Menu</button>',
+          '  <ul class="menu">',
+          '    <li class="submenu">',
+          '      <button class="menu-item" type="button">More options</button>',
+          '      <ul class="menu" id="submenu">',
+          '        <li><a class="menu-item" href="#">Sub 1</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnMenu = fixtureEl.querySelector('[data-bs-toggle="menu"]')
+        const submenuTrigger = fixtureEl.querySelector('.submenu > .menu-item')
+        const submenuWrapper = fixtureEl.querySelector('.submenu')
+        const submenu = fixtureEl.querySelector('#submenu')
+        const menu = new Menu(btnMenu)
+
+        btnMenu.addEventListener('shown.bs.menu', () => {
+          menu._openSubmenu(submenuTrigger, submenu, submenuWrapper)
+
+          // Pinned transparent synchronously so it never paints at the fallback position
+          expect(submenu.style.opacity).toEqual('0')
+
+          // Revealed once Floating UI has applied the first position
+          setTimeout(() => {
+            expect(submenu.style.opacity).toEqual('')
+            expect(submenu.style.left).not.toEqual('')
+            resolve()
+          }, 50)
+        })
+
+        menu.show()
+      })
+    })
+
     it('should close all submenus when hiding menu', () => {
       return new Promise(resolve => {
         fixtureEl.innerHTML = [
@@ -4071,6 +4111,46 @@ describe('Menu', () => {
 
       expect(menu._menu).toEqual(menuEl)
     })
+
+    it('should resolve the wrapper and menu when the toggle is not a direct child of the wrapper', () => {
+      fixtureEl.innerHTML = [
+        '<div class="wrapper">',
+        '  <span>',
+        '    <button class="btn" data-bs-toggle="menu">Menu</button>',
+        '  </span>',
+        '  <div class="menu">',
+        '    <a class="menu-item" href="#">Item</a>',
+        '  </div>',
+        '</div>'
+      ].join('')
+
+      const btnMenu = fixtureEl.querySelector('[data-bs-toggle="menu"]')
+      const wrapperEl = fixtureEl.querySelector('.wrapper')
+      const menuEl = fixtureEl.querySelector('.menu')
+      const menu = new Menu(btnMenu)
+
+      expect(menu._parent).toEqual(wrapperEl)
+      expect(menu._menu).toEqual(menuEl)
+    })
+
+    it('should fall back to the direct parent when no ancestor contains a menu', () => {
+      fixtureEl.innerHTML = [
+        '<div class="wrapper">',
+        '  <button class="btn" data-bs-toggle="menu">Menu</button>',
+        '</div>',
+        '<div class="external menu">',
+        '  <a class="menu-item" href="#">Item</a>',
+        '</div>'
+      ].join('')
+
+      const btnMenu = fixtureEl.querySelector('[data-bs-toggle="menu"]')
+      const wrapperEl = fixtureEl.querySelector('.wrapper')
+      const menuEl = fixtureEl.querySelector('.menu')
+      const menu = new Menu(btnMenu, { menu: menuEl })
+
+      expect(menu._parent).toEqual(wrapperEl)
+      expect(menu._menu).toEqual(menuEl)
+    })
   })
 
   describe('Home and End key navigation', () => {