From 8dc8a1a7ecbe88bd901d69103592f6ccda6af891 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Fri, 28 Aug 2026 12:08:33 -0700 Subject: [PATCH 1/7] 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 b1491cd8e9e0ee9a86f31d8fa4e9bbd06c1c9864 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Fri, 28 Aug 2026 12:17:45 -0700 Subject: [PATCH 2/7] 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 612f349ca43991d7cb7117e037337bbe8828f17a Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Fri, 28 Aug 2026 12:22:50 -0700 Subject: [PATCH 3/7] 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 c534dc0e1b7d..3f7472b605ba 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -38,7 +38,7 @@ }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "57.5 kB" + "maxSize": "58.0 kB" }, { "path": "./dist/js/bootstrap.js", @@ -46,7 +46,7 @@ }, { "path": "./dist/js/bootstrap.min.js", - "maxSize": "33.75 kB" + "maxSize": "34.25 kB" } ], "ci": { From 0c76af46775176cfc71fcf99925815358be29a02 Mon Sep 17 00:00:00 2001 From: Mark Otto Date: Fri, 25 Sep 2026 11:28:22 -0700 Subject: [PATCH 4/7] Handle menu state during nav overflow relocation An open nav menu kept stale classes when its item moved into overflow. Menus moved to another container were also missed, and cloned anchor triggers lost keyboard focus. Cache each hosted menu before it can move, reset open state during relocation, and keep anchor triggers focusable. Add regression coverage for relocation, nested interaction, container menus, and menu rebuilds. Raise the bundled size budget to fit the added handling. --- .bundlewatch.config.json | 2 +- js/src/nav-overflow.ts | 36 +++++++- js/tests/unit/nav-overflow.spec.js | 137 ++++++++++++++++++++++++++++- 3 files changed, 169 insertions(+), 6 deletions(-) diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index 3f7472b605ba..5bd44207dd25 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -38,7 +38,7 @@ }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "58.0 kB" + "maxSize": "58.25 kB" }, { "path": "./dist/js/bootstrap.js", diff --git a/js/src/nav-overflow.ts b/js/src/nav-overflow.ts index 746dc533f7b8..8710d6f18f09 100644 --- a/js/src/nav-overflow.ts +++ b/js/src/nav-overflow.ts @@ -83,6 +83,7 @@ class NavOverflow extends BaseComponent { protected declare _resizeObserver: ResizeObserver | null protected declare _resizeHandler: (() => void) | null protected declare _collapseBelow: number + protected declare _itemMenus: Map protected declare _relocatedMenus: Map constructor(element?: string | Element | null, config?: Partial | null) { @@ -102,6 +103,7 @@ class NavOverflow extends BaseComponent { this._resizeObserver = null this._resizeHandler = null this._collapseBelow = 0 + this._itemMenus = new Map() this._relocatedMenus = new Map() this._init() @@ -159,6 +161,11 @@ class NavOverflow extends BaseComponent { // Store original order data for (const [index, item] of this._items.entries()) { item.dataset.bsNavOrder = index as any + + const link = SelectorEngine.findOne(SELECTOR_NAV_LINK, item) + if (link?.matches(SELECTOR_MENU_TOGGLE)) { + this._findItemMenu(item, link) + } } // Resolve collapseBelow threshold once @@ -392,8 +399,8 @@ class NavOverflow extends BaseComponent { // 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._resetMenu(link, menu) + item.classList.remove(CLASS_NAME_SHOW) this._relocatedMenus.set(item, { menu, @@ -434,6 +441,10 @@ class NavOverflow extends BaseComponent { if (clonedLink.tagName === 'A') { clonedLink.setAttribute('role', 'button') + + if (!clonedLink.hasAttribute('tabindex')) { + clonedLink.setAttribute('tabindex', '0') + } } } @@ -444,18 +455,34 @@ class NavOverflow extends BaseComponent { const sibling = SelectorEngine.next(link, SELECTOR_MENU)[0] as HTMLElement | undefined if (sibling && !sibling.classList.contains(CLASS_NAME_OVERFLOW_MENU)) { + this._itemMenus.set(item, sibling) return sibling } const nested = SelectorEngine.findOne(SELECTOR_MENU, item) if (nested && !nested.classList.contains(CLASS_NAME_OVERFLOW_MENU)) { + this._itemMenus.set(item, nested) return nested } + const cached = this._itemMenus.get(item) + if (cached?.isConnected) { + return cached + } + + this._itemMenus.delete(item) return null } + protected _resetMenu(toggle: HTMLElement, menu: HTMLElement): void { + Menu.getInstance(toggle)?.dispose() + toggle.classList.remove(CLASS_NAME_SHOW) + toggle.setAttribute('aria-expanded', 'false') + toggle.parentElement?.classList.remove(CLASS_NAME_SHOW) + menu.classList.remove(CLASS_NAME_SHOW) + } + protected _restoreRelocatedMenus(): void { for (const { menu, parent, nextSibling } of this._relocatedMenus.values()) { if (nextSibling) { @@ -469,8 +496,9 @@ class NavOverflow extends BaseComponent { } protected _restoreItems(): void { - if (this._overflowToggle) { - Menu.getInstance(this._overflowToggle)?.dispose() + if (this._overflowToggle && this._overflowMenu) { + this._resetMenu(this._overflowToggle, this._overflowMenu) + this._overflowToggle.closest(SELECTOR_NAV_ITEM)?.classList.remove(CLASS_NAME_SHOW) } this._restoreRelocatedMenus() diff --git a/js/tests/unit/nav-overflow.spec.js b/js/tests/unit/nav-overflow.spec.js index 21f83b42feb4..32f16f38fd02 100644 --- a/js/tests/unit/nav-overflow.spec.js +++ b/js/tests/unit/nav-overflow.spec.js @@ -1,4 +1,5 @@ import EventHandler from '../../src/dom/event-handler.js' +import Menu from '../../src/menu.js' import NavOverflow from '../../src/nav-overflow.js' import { clearFixture, getFixture } from '../helpers/fixture.js' @@ -1127,7 +1128,7 @@ describe('NavOverflow', () => { } }) - it('should wrap an overflowing menu host as a submenu and relocate its menu', () => { + it('should wrap an overflowing menu host as a working submenu', async () => { fixtureEl.innerHTML = [ '