From 2089d0af5a866074206c550cd06e297335343f10 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Fri, 28 Aug 2026 12:08:33 -0700 Subject: [PATCH 1/3] Relocate overflowing nav menus as overflow submenus A nav item that hosts a menu used to collapse into a plain cloned link. The menu became unreachable once the nav item overflowed, so a nested menu was lost at narrow widths. Move the original `.menu` into a `.submenu` of the overflow menu instead of cloning it. One live menu keeps its ids, its nested submenus, and its node state. Only the trigger is cloned, with the menu data attributes stripped and `aria-haspopup` added. Restore moves the menu back to its nav item and disposes the menu instances that were created. --- js/src/nav-overflow.ts | 117 +++++++++++++++--- js/tests/unit/nav-overflow.spec.js | 67 ++++++++++ .../content/docs/components/nav-overflow.mdx | 55 +++++++- 3 files changed, 221 insertions(+), 18 deletions(-) diff --git a/js/src/nav-overflow.ts b/js/src/nav-overflow.ts index 3d4397b05710..a5d5b5ca038a 100644 --- a/js/src/nav-overflow.ts +++ b/js/src/nav-overflow.ts @@ -8,6 +8,7 @@ import BaseComponent from './base-component.js' import EventHandler from './dom/event-handler.js' import SelectorEngine from './dom/selector-engine.js' +import Menu from './menu.js' import { DefaultIconAllowlist, sanitizeHtml } from './util/sanitizer.js' /** @@ -26,6 +27,8 @@ const CLASS_NAME_OVERFLOW = 'nav-overflow' const CLASS_NAME_OVERFLOW_MENU = 'nav-overflow-menu' const CLASS_NAME_HIDDEN = 'd-none' const CLASS_NAME_KEEP = 'nav-overflow-keep' +const CLASS_NAME_SUBMENU = 'submenu' +const CLASS_NAME_SHOW = 'show' const SELECTOR_NAV = '.nav' const SELECTOR_NAV_ITEM = '.nav-item' @@ -33,6 +36,8 @@ const SELECTOR_NAV_LINK = '.nav-link' const SELECTOR_OVERFLOW_TOGGLE = '.nav-overflow-toggle' const SELECTOR_OVERFLOW_MENU = '.nav-overflow-menu' const SELECTOR_CUSTOM_ICON = '[data-bs-overflow-icon]' +const SELECTOR_MENU = '.menu' +const SELECTOR_MENU_TOGGLE = '[data-bs-toggle="menu"]' type NavOverflowConfig = { collapseBelow: number | string @@ -78,6 +83,7 @@ class NavOverflow extends BaseComponent { protected declare _resizeObserver: ResizeObserver | null protected declare _resizeHandler: (() => void) | null protected declare _collapseBelow: number + protected declare _relocatedMenus: Map constructor(element?: string | Element | null, config?: Partial | null) { super(element, config) @@ -96,6 +102,7 @@ class NavOverflow extends BaseComponent { this._resizeObserver = null this._resizeHandler = null this._collapseBelow = 0 + this._relocatedMenus = new Map() this._init() } @@ -357,8 +364,7 @@ class NavOverflow extends BaseComponent { return } - // Clear existing overflow items - this._overflowMenu.innerHTML = '' + this._overflowMenu.replaceChildren() this._overflowItems = [] for (const item of items) { @@ -367,20 +373,14 @@ class NavOverflow extends BaseComponent { continue } - const clonedLink = link.cloneNode(true) as HTMLElement - clonedLink.className = 'menu-item' + const menu = this._findItemMenu(item, link) - if (link.classList.contains('active')) { - clonedLink.classList.add('active') - } - - if (link.classList.contains('disabled') || link.hasAttribute('disabled')) { - clonedLink.classList.add('disabled') + if (menu && link.matches(SELECTOR_MENU_TOGGLE)) { + this._overflowMenu.append(this._relocateAsSubmenu(item, link, menu)) + } else { + this._overflowMenu.append(this._cloneAsMenuItem(link)) } - this._overflowMenu.append(clonedLink) - - // Hide original item item.classList.add(CLASS_NAME_HIDDEN) item.dataset.bsNavOverflow = 'true' @@ -388,7 +388,93 @@ class NavOverflow extends BaseComponent { } } + // A nav item that already hosts a Menu becomes a submenu of the overflow + // menu. Move the original `.menu` (do not clone it) so nested submenus, + // ids, and live node state stay on one element. + protected _relocateAsSubmenu(item: HTMLElement, link: HTMLElement, menu: HTMLElement): HTMLElement { + Menu.getInstance(link)?.dispose() + menu.classList.remove(CLASS_NAME_SHOW) + + this._relocatedMenus.set(item, { + menu, + parent: menu.parentNode!, + nextSibling: menu.nextSibling + }) + + const submenu = document.createElement('div') + submenu.className = CLASS_NAME_SUBMENU + submenu.append(this._cloneAsMenuItem(link, true), menu) + + return submenu + } + + protected _cloneAsMenuItem(link: HTMLElement, submenu = false): HTMLElement { + const clonedLink = link.cloneNode(true) as HTMLElement + clonedLink.className = 'menu-item' + clonedLink.removeAttribute('id') + + if (link.classList.contains('active')) { + clonedLink.classList.add('active') + } + + if (link.classList.contains('disabled') || link.hasAttribute('disabled')) { + clonedLink.classList.add('disabled') + } + + if (submenu) { + for (const name of clonedLink.getAttributeNames()) { + if (name.startsWith('data-bs-') && name !== 'data-bs-theme') { + clonedLink.removeAttribute(name) + } + } + + clonedLink.removeAttribute('href') + clonedLink.setAttribute('aria-haspopup', 'true') + clonedLink.setAttribute('aria-expanded', 'false') + + if (clonedLink.tagName === 'A') { + clonedLink.setAttribute('role', 'button') + } + } + + return clonedLink + } + + protected _findItemMenu(item: HTMLElement, link: HTMLElement): HTMLElement | null { + const sibling = SelectorEngine.next(link, SELECTOR_MENU)[0] as HTMLElement | undefined + + if (sibling && !sibling.classList.contains(CLASS_NAME_OVERFLOW_MENU)) { + return sibling + } + + const nested = SelectorEngine.findOne(SELECTOR_MENU, item) + + if (nested && !nested.classList.contains(CLASS_NAME_OVERFLOW_MENU)) { + return nested + } + + return null + } + + protected _restoreRelocatedMenus(): void { + for (const { menu, parent, nextSibling } of this._relocatedMenus.values()) { + if (nextSibling) { + nextSibling.before(menu) + } else { + parent.append(menu) + } + } + + this._relocatedMenus.clear() + } + protected _restoreItems(): void { + if (this._overflowToggle) { + Menu.getInstance(this._overflowToggle)?.dispose() + } + + this._restoreRelocatedMenus() + for (const item of this._items) { item.classList.remove(CLASS_NAME_HIDDEN) delete item.dataset.bsNavOverflow @@ -397,10 +483,7 @@ class NavOverflow extends BaseComponent { // Show the toggle too, so it is measured at its real width and not zero this._overflowToggle?.closest(SELECTOR_NAV_ITEM)?.classList.remove(CLASS_NAME_HIDDEN) - if (this._overflowMenu) { - this._overflowMenu.innerHTML = '' - } - + this._overflowMenu?.replaceChildren() this._overflowItems = [] } } diff --git a/js/tests/unit/nav-overflow.spec.js b/js/tests/unit/nav-overflow.spec.js index 8f612b7237ef..21f83b42feb4 100644 --- a/js/tests/unit/nav-overflow.spec.js +++ b/js/tests/unit/nav-overflow.spec.js @@ -1127,6 +1127,73 @@ describe('NavOverflow', () => { } }) + it('should wrap an overflowing menu host as a submenu and relocate its menu', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const wrapperEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]') + const productsMenu = wrapperEl.querySelector('#products-menu') + const navOverflow = new NavOverflow(wrapperEl) + const overflowMenu = wrapperEl.querySelector('.nav-overflow-menu') + const submenu = overflowMenu.querySelector(':scope > .submenu') + const trigger = submenu?.querySelector(':scope > .menu-item') + + expect(wrapperEl.querySelector('#products-item')).toHaveClass('d-none') + expect(submenu).not.toBeNull() + expect(trigger.textContent).toEqual('Products') + expect(trigger.getAttribute('data-bs-toggle')).toBeNull() + expect(trigger.getAttribute('aria-haspopup')).toEqual('true') + expect(submenu.querySelector(':scope > .menu')).toEqual(productsMenu) + expect(productsMenu.querySelector('.submenu > .menu-item').textContent).toEqual('Phones') + + navOverflow.dispose() + }) + + it('should move a relocated menu back onto its nav item on restore', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const wrapperEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]') + const productsItem = wrapperEl.querySelector('#products-item') + const productsMenu = wrapperEl.querySelector('#products-menu') + const navOverflow = new NavOverflow(wrapperEl) + + expect(productsMenu.parentElement).toHaveClass('submenu') + + navOverflow.dispose() + + expect(productsMenu.parentElement).toEqual(productsItem) + expect(productsItem).not.toHaveClass('d-none') + }) + it('should restore hidden items on dispose', () => { fixtureEl.innerHTML = [ ' `} /> +### With menus + +A nav item that already hosts a [menu]([[docsref:/components/menu]]) becomes a submenu of the overflow menu. The plugin moves the original `.menu` rather than cloning it, so nested submenus stay intact. + + + + `} /> + ## Customizing the toggle ### Custom text From 0ec3d574c16ac112db86ba93b0e036de3cc6909a Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Fri, 28 Aug 2026 12:17:45 -0700 Subject: [PATCH 2/3] Type a relocated menu parent as ParentNode `tsc` rejected `parent.append(menu)`, because `Node` does not declare `append`. Store the parent as `ParentNode`, which does, and which is what `Node.parentNode` already returns. --- js/src/nav-overflow.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/js/src/nav-overflow.ts b/js/src/nav-overflow.ts index a5d5b5ca038a..746dc533f7b8 100644 --- a/js/src/nav-overflow.ts +++ b/js/src/nav-overflow.ts @@ -83,7 +83,7 @@ class NavOverflow extends BaseComponent { protected declare _resizeObserver: ResizeObserver | null protected declare _resizeHandler: (() => void) | null protected declare _collapseBelow: number - protected declare _relocatedMenus: Map + protected declare _relocatedMenus: Map constructor(element?: string | Element | null, config?: Partial | null) { super(element, config) From 8dfa0f919a1eadfa9c987ad17d05a93bb420cce5 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Fri, 28 Aug 2026 12:22:50 -0700 Subject: [PATCH 3/3] Raise the JS bundlewatch budgets The submenu relocation adds about 0.4KB gzipped to the minified builds, and both budgets had under 0.1KB of headroom. Move `bootstrap.min.js` to 34.0KB and `bootstrap.bundle.min.js` to 56.0KB. --- .bundlewatch.config.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 92e5191fbf05..b44e40fd3295 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -38,7 +38,7 @@ }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "55.5 kB" + "maxSize": "56.0 kB" }, { "path": "./dist/js/bootstrap.js", @@ -46,7 +46,7 @@ }, { "path": "./dist/js/bootstrap.min.js", - "maxSize": "33.5 kB" + "maxSize": "34.0 kB" } ], "ci": {