diff --git a/.bundlewatch.config.json b/.bundlewatch.config.json index c534dc0e1b7d..d5d4fa1ced8a 100644 --- a/.bundlewatch.config.json +++ b/.bundlewatch.config.json @@ -34,11 +34,11 @@ }, { "path": "./dist/js/bootstrap.bundle.js", - "maxSize": "73.0 kB" + "maxSize": "73.25 kB" }, { "path": "./dist/js/bootstrap.bundle.min.js", - "maxSize": "57.5 kB" + "maxSize": "58.25 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": { diff --git a/js/src/nav-overflow.ts b/js/src/nav-overflow.ts index 3d4397b05710..9796ef8ba6f0 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,9 @@ 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_INITIALIZED = 'nav-overflow-initialized' +const CLASS_NAME_SUBMENU = 'submenu' +const CLASS_NAME_SHOW = 'show' const SELECTOR_NAV = '.nav' const SELECTOR_NAV_ITEM = '.nav-item' @@ -33,11 +37,14 @@ 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 iconPlacement: string menuPlacement: string + menuStrategy: string moreText: string | false moreIcon: string threshold: number @@ -50,6 +57,7 @@ const Default: NavOverflowConfig = { collapseBelow: 0, iconPlacement: 'start', menuPlacement: 'bottom-end', + menuStrategy: 'absolute', moreText: DEFAULT_TEXT, moreIcon: '', threshold: 0 // Minimum items to keep visible before showing overflow @@ -59,6 +67,7 @@ const DefaultType = { collapseBelow: '(number|string)', iconPlacement: 'string', menuPlacement: 'string', + menuStrategy: 'string', moreText: '(string|boolean)', moreIcon: 'string', threshold: 'number' @@ -78,6 +87,8 @@ 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) { super(element, config) @@ -96,6 +107,8 @@ class NavOverflow extends BaseComponent { this._resizeObserver = null this._resizeHandler = null this._collapseBelow = 0 + this._itemMenus = new Map() + this._relocatedMenus = new Map() this._init() } @@ -152,6 +165,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 @@ -165,6 +183,7 @@ class NavOverflow extends BaseComponent { // Initial calculation this._calculateOverflow() + this._element.classList.add(CLASS_NAME_INITIALIZED) } protected _createOverflowMenu(): void { @@ -189,6 +208,7 @@ class NavOverflow extends BaseComponent { button.className = 'nav-link nav-overflow-toggle' button.setAttribute('data-bs-toggle', 'menu') button.setAttribute('data-bs-placement', this._config.menuPlacement) + button.setAttribute('data-bs-strategy', this._config.menuStrategy) button.setAttribute('aria-expanded', 'false') // An icon-only toggle still needs a name. Fall back to the default text, @@ -357,8 +377,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 +386,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 +401,114 @@ 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 { + this._resetMenu(link, menu) + item.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') + + if (!clonedLink.hasAttribute('tabindex')) { + clonedLink.setAttribute('tabindex', '0') + } + } + } + + 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)) { + 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) { + nextSibling.before(menu) + } else { + parent.append(menu) + } + } + + this._relocatedMenus.clear() + } + protected _restoreItems(): void { + if (this._overflowToggle && this._overflowMenu) { + this._resetMenu(this._overflowToggle, this._overflowMenu) + this._overflowToggle.closest(SELECTOR_NAV_ITEM)?.classList.remove(CLASS_NAME_SHOW) + } + + this._restoreRelocatedMenus() + for (const item of this._items) { item.classList.remove(CLASS_NAME_HIDDEN) delete item.dataset.bsNavOverflow @@ -397,10 +517,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..e3301eec5b95 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' @@ -27,6 +28,7 @@ describe('NavOverflow', () => { it('should return plugin default config', () => { expect(NavOverflow.Default).toEqual(jasmine.any(Object)) expect(NavOverflow.Default.moreText).toEqual('More') + expect(NavOverflow.Default.menuStrategy).toEqual('absolute') expect(NavOverflow.Default.threshold).toEqual(0) }) }) @@ -97,6 +99,7 @@ describe('NavOverflow', () => { const navOverflow = new NavOverflow(wrapperEl) expect(wrapperEl).toHaveClass('nav-overflow') + expect(wrapperEl).toHaveClass('nav-overflow-initialized') expect(wrapperEl.querySelector('.nav')).not.toHaveClass('nav-overflow') navOverflow.dispose() @@ -652,6 +655,7 @@ describe('NavOverflow', () => { collapseBelow: '(number|string)', iconPlacement: 'string', menuPlacement: 'string', + menuStrategy: 'string', moreText: '(string|boolean)', moreIcon: 'string', threshold: 'number' @@ -741,6 +745,25 @@ describe('NavOverflow', () => { navOverflow.dispose() }) + it('should pass menuStrategy to the generated menu toggle', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const wrapperEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]') + const navOverflow = new NavOverflow(wrapperEl, { + menuStrategy: 'fixed' + }) + + expect(wrapperEl.querySelector('.nav-overflow-toggle').getAttribute('data-bs-strategy')).toEqual('fixed') + + navOverflow.dispose() + }) + it('should use a child element with [data-bs-overflow-icon] as the icon', () => { fixtureEl.innerHTML = [ '`} /> + ## Customizing the toggle ### Custom text @@ -347,6 +400,7 @@ const navOverflow = new bootstrap.NavOverflow(wrapperElement, { | `collapseBelow` | number\|string | `0` | Wrapper width below which all items collapse into the overflow dropdown. Pass a breakpoint name (e.g., `'md'`) to resolve from `--bs-breakpoint-{name}`, or a number for a direct pixel value. `0` disables. | | `iconPlacement` | string | `'start'` | Position of the icon relative to the text in the toggle button. Use `'start'` for before the text or `'end'` for after. | | `menuPlacement` | string | `'bottom-end'` | Placement of the overflow dropdown menu, passed as `data-bs-placement` to the menu toggle. | +| `menuStrategy` | string | `'absolute'` | Positioning strategy for the overflow menu, passed as `data-bs-strategy` to the menu toggle. Use `'fixed'` when an overflow ancestor would clip the menu. | | `moreText` | string\|boolean | `'More'` | Text label for the overflow toggle button. Inserted as plain text. Use `false` (or an empty string) for an icon-only toggle, which is labeled `aria-label="More"` instead. | | `moreIcon` | string | `'...'` | SVG or HTML icon for the overflow toggle button. Passed through the [icon content sanitizer]([[docsref:/getting-started/javascript#sanitizer]]) before insertion. Overridden by a child element with `data-bs-overflow-icon` if present (also sanitized). | | `threshold` | number | `0` | Minimum number of items to keep visible before showing overflow. |