Skip to content
Merged
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
6 changes: 3 additions & 3 deletions .bundlewatch.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,19 +34,19 @@
},
{
"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",
"maxSize": "60.25 kB"
},
{
"path": "./dist/js/bootstrap.min.js",
"maxSize": "33.75 kB"
"maxSize": "34.25 kB"
}
],
"ci": {
Expand Down
151 changes: 134 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,18 +27,24 @@ 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'
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
Expand All @@ -50,6 +57,7 @@ const Default: NavOverflowConfig = {
collapseBelow: 0,
iconPlacement: 'start',
menuPlacement: 'bottom-end',
menuStrategy: 'absolute',
moreText: DEFAULT_TEXT,
moreIcon: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M3 9.5a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3m5 0a1.5 1.5 0 1 1 0-3 1.5 1.5 0 0 1 0 3"/></svg>',
threshold: 0 // Minimum items to keep visible before showing overflow
Expand All @@ -59,6 +67,7 @@ const DefaultType = {
collapseBelow: '(number|string)',
iconPlacement: 'string',
menuPlacement: 'string',
menuStrategy: 'string',
moreText: '(string|boolean)',
moreIcon: 'string',
threshold: 'number'
Expand All @@ -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<HTMLElement, HTMLElement>
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 +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()
}
Expand Down Expand Up @@ -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
Expand All @@ -165,6 +183,7 @@ class NavOverflow extends BaseComponent {

// Initial calculation
this._calculateOverflow()
this._element.classList.add(CLASS_NAME_INITIALIZED)
}

protected _createOverflowMenu(): void {
Expand All @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -367,28 +386,129 @@ 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 {
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
Expand All @@ -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 = []
}
}
Expand Down
Loading
Loading