From: Mark Otto Date: Sun, 21 Jun 2026 04:59:54 +0000 (-0700) Subject: Menus: support nested toggles and fix submenu position flash (#42533) X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=6381aa54779a5aef718e942e6f870d19c8a6768a;p=thirdparty%2Fbootstrap.git Menus: support nested toggles and fix submenu position flash (#42533) * fix that * update --- diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index cd2034c8f8..9da0126d36 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -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", diff --git a/js/src/dom/selector-engine.js b/js/src/dom/selector-engine.js index 9e83d2064a..7c28e178eb 100644 --- a/js/src/dom/selector-engine.js +++ b/js/src/dom/selector-engine.js @@ -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 diff --git a/js/src/menu.js b/js/src/menu.js index f70a134576..28599a8f1a 100644 --- a/js/src/menu.js +++ b/js/src/menu.js @@ -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) diff --git a/js/tests/unit/dom/selector-engine.spec.js b/js/tests/unit/dom/selector-engine.spec.js index f0ec5faf9a..2ade260577 100644 --- a/js/tests/unit/dom/selector-engine.spec.js +++ b/js/tests/unit/dom/selector-engine.spec.js @@ -81,6 +81,33 @@ describe('SelectorEngine', () => { }) }) + describe('closest', () => { + it('should return the element itself when it matches the selector', () => { + fixtureEl.innerHTML = '
' + + const element = fixtureEl.querySelector('#element') + + expect(SelectorEngine.closest(element, '#element')).toEqual(element) + }) + + it('should return the closest ancestor matching the selector', () => { + fixtureEl.innerHTML = '
' + + 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 = '
' + + const element = fixtureEl.querySelector('#element') + + expect(SelectorEngine.closest(element, '.missing')).toBeNull() + }) + }) + describe('prev', () => { it('should return previous element', () => { fixtureEl.innerHTML = '
' diff --git a/js/tests/unit/menu.spec.js b/js/tests/unit/menu.spec.js index a02005ac5c..c88d54891e 100644 --- a/js/tests/unit/menu.spec.js +++ b/js/tests/unit/menu.spec.js @@ -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 = [ + '
', + ' ', + ' ', + '
' + ].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 = [ + '
', + ' ', + ' ', + ' ', + ' ', + '
' + ].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 = [ + '
', + ' ', + '
', + '' + ].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', () => {