Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,15 @@
},
{
"path": "./dist/js/bootstrap.bundle.min.js",
"maxSize": "55.5 kB"
"maxSize": "56.0 kB"
},
{
"path": "./dist/js/bootstrap.js",
"maxSize": "60.25 kB"
},
{
"path": "./dist/js/bootstrap.min.js",
"maxSize": "33.5 kB"
"maxSize": "34.0 kB"
}
],
"ci": {
Expand Down
117 changes: 100 additions & 17 deletions js/src/nav-overflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand All @@ -26,13 +27,17 @@ 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'
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
Expand Down Expand Up @@ -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<HTMLElement, { menu: HTMLElement, parent: ParentNode, nextSibling: ChildNode | null }>

constructor(element?: string | Element | null, config?: Partial<NavOverflowConfig> | null) {
super(element, config)
Expand All @@ -96,6 +102,7 @@ class NavOverflow extends BaseComponent {
this._resizeObserver = null
this._resizeHandler = null
this._collapseBelow = 0
this._relocatedMenus = new Map()

this._init()
}
Expand Down Expand Up @@ -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) {
Expand All @@ -367,28 +373,108 @@ 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'

this._overflowItems.push(item)
}
}

// 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
Expand All @@ -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 = []
}
}
Expand Down
67 changes: 67 additions & 0 deletions js/tests/unit/nav-overflow.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1127,6 +1127,73 @@ describe('NavOverflow', () => {
}
})

it('should wrap an overflowing menu host as a submenu and relocate its menu', () => {
fixtureEl.innerHTML = [
'<div class="nav-overflow" style="width: 150px;" data-bs-toggle="nav-overflow">',
' <ul class="nav" style="display: flex;">',
' <li class="nav-item" style="flex: 0 0 100px; width: 100px;"><a class="nav-link" href="#">Home</a></li>',
' <li class="nav-item" id="products-item" style="flex: 0 0 100px; width: 100px;">',
' <button class="nav-link" type="button" data-bs-toggle="menu" aria-expanded="false">Products</button>',
' <div class="menu" id="products-menu">',
' <a class="menu-item" href="#">Laptops</a>',
' <div class="submenu">',
' <button class="menu-item" type="button">Phones</button>',
' <div class="menu">',
' <a class="menu-item" href="#">iPhone</a>',
' </div>',
' </div>',
' </div>',
' </li>',
' </ul>',
'</div>'
].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 = [
'<div class="nav-overflow" style="width: 150px;" data-bs-toggle="nav-overflow">',
' <ul class="nav" style="display: flex;">',
' <li class="nav-item" style="flex: 0 0 100px; width: 100px;"><a class="nav-link" href="#">Home</a></li>',
' <li class="nav-item" id="products-item" style="flex: 0 0 100px; width: 100px;">',
' <button class="nav-link" type="button" data-bs-toggle="menu">Products</button>',
' <div class="menu" id="products-menu">',
' <a class="menu-item" href="#">Laptops</a>',
' </div>',
' </li>',
' </ul>',
'</div>'
].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 = [
'<div class="nav-overflow" style="width: 250px;" data-bs-toggle="nav-overflow">',
Expand Down
55 changes: 54 additions & 1 deletion site/src/content/docs/components/nav-overflow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Here’s what you need to know before getting started:
- Wrap your `.nav` in a `.nav-overflow` element and add `data-bs-toggle="nav-overflow"` to that wrapper.
- **Responds to container size**, not viewport size. The component watches the wrapper with a ResizeObserver, so it works in embedded contexts, documentation examples, and responsive containers.
- The wrapper is what makes this reliable. Collapsing items changes the width of the nav, so the component measures the wrapper instead. Measuring the nav would feed the result back in as the input.
- Overflow items are cloned into a "More" menu while the originals are hidden.
- Overflow items are cloned into a "More" menu while the originals are hidden. A nav item that hosts a menu is the exception: its `.menu` moves into a submenu of the overflow menu.
- Works with all nav styles: default, pills, tabs, and underline.
- Active and disabled states are preserved in the overflow menu.

Expand Down Expand Up @@ -195,6 +195,59 @@ The nav overflow pattern can also be used within a [navbar]([[docsref:/component
</div>
</nav>`} />

### 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.

<ResizableExample code={`<div class="nav-overflow" data-bs-toggle="nav-overflow">
<ul class="nav nav-pills">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Dashboard</a>
</li>
<li class="nav-item">
<button class="nav-link" type="button" data-bs-toggle="menu" aria-expanded="false">
Products
</button>
<div class="menu">
<a class="menu-item" href="#">Laptops</a>
<div class="submenu">
<button class="menu-item" type="button">Phones</button>
<div class="menu">
<a class="menu-item" href="#">iPhone</a>
<a class="menu-item" href="#">Android</a>
</div>
</div>
<a class="menu-item" href="#">Tablets</a>
</div>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Services</a>
</li>
<li class="nav-item">
<button class="nav-link" type="button" data-bs-toggle="menu" aria-expanded="false">
Company
</button>
<div class="menu">
<a class="menu-item" href="#">About</a>
<a class="menu-item" href="#">Careers</a>
<a class="menu-item" href="#">Press</a>
</div>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Analytics</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Reports</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#">Help</a>
</li>
</ul>
</div>`} />

## Customizing the toggle

### Custom text
Expand Down