},
{
"path": "./dist/js/bootstrap.bundle.js",
- "maxSize": "80.25 kB"
+ "maxSize": "81.0 kB"
},
{
"path": "./dist/js/bootstrap.bundle.min.js",
},
{
"path": "./dist/js/bootstrap.js",
- "maxSize": "51.5 kB"
+ "maxSize": "52.25 kB"
},
{
"path": "./dist/js/bootstrap.min.js",
return parents
},
+ closest(element, selector) {
+ return Element.prototype.closest.call(element, selector)
+ },
+
prev(element, selector) {
let previous = element.previousElementSibling
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()
// 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) {
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)
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() {
]
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)
})
})
+ 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>'
})
})
+ 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 = [
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', () => {