Add NavOverflow component and use it in the preview navbar (#3240)

Co-authored-by: codecalm <codecalm@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: codecalm <1282324+codecalm@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-05 23:24:59 +02:00
1 parent 681b6eb671
commit 6c46d55336
16 files changed
+1914 -7

No files matched your search

+3
View File
@@ -6,6 +6,7 @@ export { default as Carousel } from './bootstrap/carousel'
export { default as Collapse } from './bootstrap/collapse'
export { default as Dropdown } from './bootstrap/dropdown'
export { default as Modal } from './bootstrap/modal'
export { default as NavOverflow } from './bootstrap/nav-overflow'
export { default as Offcanvas } from './bootstrap/offcanvas'
export { default as Popover } from './bootstrap/popover'
export { default as ScrollSpy } from './bootstrap/scrollspy'
@@ -19,6 +20,7 @@ import Carousel from './bootstrap/carousel'
import Collapse from './bootstrap/collapse'
import Dropdown from './bootstrap/dropdown'
import Modal from './bootstrap/modal'
import NavOverflow from './bootstrap/nav-overflow'
import Offcanvas from './bootstrap/offcanvas'
import Popover from './bootstrap/popover'
import ScrollSpy from './bootstrap/scrollspy'
@@ -33,6 +35,7 @@ export const bootstrap = {
Collapse,
Dropdown,
Modal,
NavOverflow,
Offcanvas,
Popover,
ScrollSpy,
+656
View File
@@ -0,0 +1,656 @@
/**
* --------------------------------------------------------------------------
* Bootstrap nav-overflow.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component'
import Dropdown from './dropdown'
import EventHandler from './dom/event-handler'
import SelectorEngine from './dom/selector-engine'
import { defineJQueryPlugin, onDOMContentLoaded } from './util/index'
import { DefaultIconAllowlist, sanitizeHtml } from './util/sanitizer'
import type { ComponentConfig as BaseComponentConfig, ElementSelector, JQueryCollectionLike } from './types'
type MenuPlacement = 'bottom-end' | 'bottom-start' | 'top-end' | 'top-start'
type ComponentConfig = {
collapseBelow: number | string
iconPlacement: 'start' | 'end'
menuPlacement: MenuPlacement
menuStrategy: 'absolute' | 'fixed'
moreText: string | false
moreIcon: string
threshold: number
}
type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
type RelocatedMenu = {
menu: HTMLElement
parent: ParentNode
nextSibling: ChildNode | null
}
/**
* Constants
*/
const NAME = 'navoverflow'
const DATA_KEY = 'bs.navoverflow'
const EVENT_KEY = `.${DATA_KEY}`
const EVENT_UPDATE = `update${EVENT_KEY}`
const EVENT_OVERFLOW = `overflow${EVENT_KEY}`
const EVENT_RESIZE = `resize${EVENT_KEY}`
const EVENT_CLICK = `click${EVENT_KEY}`
const EVENT_TAB_SHOWN = 'shown.bs.tab'
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 = 'dropend'
const CLASS_NAME_SUBMENU_START = 'dropstart'
const CLASS_NAME_DROPUP = 'dropup'
const CLASS_NAME_MENU_END = 'dropdown-menu-end'
const CLASS_NAME_SHOW = 'show'
const CLASS_NAME_ACTIVE = 'active'
const SELECTOR_NAV = '.nav, .navbar-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], [data-tblr-overflow-icon]'
const SELECTOR_MENU = '.dropdown-menu'
const SELECTOR_MENU_TOGGLE = '[data-bs-toggle="dropdown"], [data-tblr-toggle="dropdown"]'
const SELECTOR_ANY_TOGGLE = '[data-bs-toggle], [data-tblr-toggle]'
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="nav-overflow"], [data-tblr-toggle="nav-overflow"]'
const DEFAULT_TEXT = 'More'
const Default: ComponentConfig = {
collapseBelow: 0,
iconPlacement: 'start',
menuPlacement: 'bottom-end',
menuStrategy: 'absolute',
moreText: DEFAULT_TEXT,
moreIcon:
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon"><path d="M4 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M18 12a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>',
threshold: 0,
}
const DefaultType: Record<keyof ComponentConfig, string> = {
collapseBelow: '(number|string)',
iconPlacement: 'string',
menuPlacement: 'string',
menuStrategy: 'string',
moreText: '(string|boolean)',
moreIcon: 'string',
threshold: 'number',
}
/**
* Class definition
*/
class NavOverflow extends BaseComponent {
declare _element: HTMLElement
declare _config: ComponentConfig
_nav: HTMLElement
_items: HTMLElement[]
_overflowItems: HTMLElement[]
_lastOverflow: HTMLElement[]
_overflowMenu: HTMLElement | null
_overflowToggle: HTMLElement | null
_ownsToggle: boolean
_ownsClass: boolean
_clones: Map<HTMLElement, HTMLElement>
_resizeObserver: ResizeObserver | null
_resizeHandler: (() => void) | null
_collapseBelow: number
_itemMenus: Map<HTMLElement, HTMLElement>
_relocatedMenus: Map<HTMLElement, RelocatedMenu>
constructor(element: ElementSelector, config?: ComponentConfigInput) {
super(element, config)
const nav = SelectorEngine.findOne(SELECTOR_NAV, this._element)
if (!nav) {
throw new TypeError(`${this._element.outerHTML} has no child ${SELECTOR_NAV} to collapse`)
}
this._nav = nav
this._items = []
this._overflowItems = []
this._lastOverflow = []
this._overflowMenu = null
this._overflowToggle = null
this._ownsToggle = false
this._ownsClass = false
this._clones = new Map()
this._resizeObserver = null
this._resizeHandler = null
this._collapseBelow = 0
this._itemMenus = new Map()
this._relocatedMenus = new Map()
this._init()
}
// Getters
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): Record<keyof ComponentConfig, string> {
return DefaultType
}
static get NAME(): string {
return NAME
}
// Public
update(): void {
this._calculateOverflow()
EventHandler.trigger(this._element, EVENT_UPDATE)
}
dispose(): void {
this._resizeObserver?.disconnect()
if (this._resizeHandler) {
EventHandler.off(window, EVENT_RESIZE, this._resizeHandler)
}
EventHandler.off(this._element, EVENT_KEY)
this._restoreItems()
if (this._ownsToggle) {
this._overflowToggle?.closest(SELECTOR_NAV_ITEM)?.remove()
}
this._element.classList.remove(CLASS_NAME_INITIALIZED)
if (this._ownsClass) {
this._element.classList.remove(CLASS_NAME_OVERFLOW)
}
super.dispose()
}
// Private
_init(): void {
this._ownsClass = !this._element.classList.contains(CLASS_NAME_OVERFLOW)
this._element.classList.add(CLASS_NAME_OVERFLOW)
this._items = SelectorEngine.find(SELECTOR_NAV_ITEM, this._nav).filter((item) => item.parentElement === this._nav && !item.querySelector(SELECTOR_OVERFLOW_TOGGLE))
for (const item of this._items) {
const link = SelectorEngine.findOne(SELECTOR_NAV_LINK, item)
if (link?.matches(SELECTOR_MENU_TOGGLE)) {
this._findItemMenu(item, link)
}
}
// Tabs switch on the original link, so copy the new state to the menu
EventHandler.on(this._element, `${EVENT_TAB_SHOWN}${EVENT_KEY}`, () => this._syncStates())
this._collapseBelow = this._resolveCollapseBelow()
this._createOverflowMenu()
this._setupResizeObserver()
this._calculateOverflow()
this._element.classList.add(CLASS_NAME_INITIALIZED)
}
_createOverflowMenu(): void {
this._overflowToggle = SelectorEngine.findOne(SELECTOR_OVERFLOW_TOGGLE, this._element)
if (this._overflowToggle) {
this._overflowMenu = SelectorEngine.findOne(SELECTOR_OVERFLOW_MENU, this._element)
return
}
const { moreText, menuPlacement, iconPlacement } = this._config
const label = typeof moreText === 'string' ? moreText : ''
const overflowItem = document.createElement('li')
overflowItem.className = 'nav-item dropdown nav-overflow-item'
if (menuPlacement.startsWith('top')) {
overflowItem.classList.add(CLASS_NAME_DROPUP)
}
const button = document.createElement('button')
button.type = 'button'
button.className = 'nav-link nav-overflow-toggle'
button.setAttribute('data-bs-toggle', 'dropdown')
button.setAttribute('aria-expanded', 'false')
if (label === '') {
button.setAttribute('aria-label', DEFAULT_TEXT)
}
const iconSpan = document.createElement('span')
iconSpan.className = 'nav-overflow-icon'
iconSpan.innerHTML = sanitizeHtml(this._resolveIcon(), DefaultIconAllowlist)
if (label === '') {
button.append(iconSpan)
} else {
const textSpan = document.createElement('span')
textSpan.className = 'nav-overflow-text'
textSpan.textContent = label
if (iconPlacement === 'end') {
button.append(textSpan, iconSpan)
} else {
button.append(iconSpan, textSpan)
}
}
const menu = document.createElement('div')
menu.className = `dropdown-menu ${CLASS_NAME_OVERFLOW_MENU}`
if (menuPlacement.endsWith('end')) {
menu.classList.add(CLASS_NAME_MENU_END)
}
overflowItem.append(button, menu)
this._nav.append(overflowItem)
this._overflowToggle = button
this._overflowMenu = menu
this._ownsToggle = true
}
_resolveIcon(): string {
const customIconElement = SelectorEngine.findOne(SELECTOR_CUSTOM_ICON, this._element)
if (!customIconElement) {
return this._config.moreIcon
}
const iconClone = customIconElement.cloneNode(true) as HTMLElement
iconClone.removeAttribute('data-bs-overflow-icon')
iconClone.removeAttribute('data-tblr-overflow-icon')
customIconElement.remove()
return iconClone.outerHTML
}
_resolveCollapseBelow(): number {
const value = this._config.collapseBelow
if (typeof value === 'number') {
return value
}
if (value === '') {
return 0
}
const numeric = Number(value)
if (!Number.isNaN(numeric)) {
return numeric
}
const cssValue = getComputedStyle(document.documentElement).getPropertyValue(`--tblr-breakpoint-${value}`)
return Number.parseFloat(cssValue) || 0
}
_setupResizeObserver(): void {
if (typeof ResizeObserver === 'undefined') {
this._resizeHandler = () => this._calculateOverflow()
EventHandler.on(window, EVENT_RESIZE, this._resizeHandler)
return
}
// Observe the wrapper, never the nav: collapsing items resizes the nav and would loop
this._resizeObserver = new ResizeObserver(() => {
this._calculateOverflow()
})
this._resizeObserver.observe(this._element)
}
_availableWidth(): number {
const { paddingInlineStart, paddingInlineEnd } = getComputedStyle(this._element)
const padding = (Number.parseFloat(paddingInlineStart) || 0) + (Number.parseFloat(paddingInlineEnd) || 0)
return this._element.clientWidth - padding
}
_navGap(): number {
return Number.parseFloat(getComputedStyle(this._nav).columnGap) || 0
}
_calculateOverflow(): void {
const overflowItem = this._overflowToggle?.closest<HTMLElement>(SELECTOR_NAV_ITEM) ?? null
// Measure with every item shown, so widths never depend on the last pass
this._setHidden([], overflowItem, false)
const itemsToOverflow = this._getItemsToOverflow(overflowItem)
// Leave the menu alone when nothing moves, so a resize does not close it
if (itemsToOverflow.length === this._lastOverflow.length && itemsToOverflow.every((item, index) => item === this._lastOverflow[index])) {
this._setHidden(this._overflowItems, overflowItem)
return
}
this._restoreItems()
this._lastOverflow = itemsToOverflow
this._applyOverflow(itemsToOverflow, overflowItem)
}
_getItemsToOverflow(overflowItem: HTMLElement | null): HTMLElement[] {
// A vertical nav, like a collapsed navbar, has room for every item
if (getComputedStyle(this._nav).flexDirection.startsWith('column')) {
return []
}
const availableWidth = this._availableWidth()
const candidates = this._items.filter((item) => !item.classList.contains(CLASS_NAME_KEEP))
if (this._collapseBelow > 0 && availableWidth < this._collapseBelow) {
return candidates
}
const gap = this._navGap()
const keepWidth = this._items.filter((item) => item.classList.contains(CLASS_NAME_KEEP)).reduce((sum, item) => sum + item.offsetWidth + gap, 0)
const overflowWidth = overflowItem ? overflowItem.offsetWidth + gap : 0
const limit = availableWidth - keepWidth - overflowWidth
let usedWidth = 0
let itemsToOverflow: HTMLElement[] = []
for (const item of candidates) {
usedWidth += item.offsetWidth + gap
if (usedWidth > limit + 1) {
itemsToOverflow.push(item)
}
}
// Everything fits once the toggle is gone, so do not reserve room for it
if (itemsToOverflow.length > 0 && usedWidth <= availableWidth - keepWidth + 1) {
itemsToOverflow = []
}
const { threshold } = this._config
const visibleCount = this._items.length - itemsToOverflow.length
if (visibleCount < threshold && this._items.length > threshold) {
itemsToOverflow = this._items.slice(threshold).filter((item) => !item.classList.contains(CLASS_NAME_KEEP))
}
return itemsToOverflow
}
_setHidden(items: HTMLElement[], overflowItem: HTMLElement | null, hideToggle = items.length === 0): void {
for (const item of this._items) {
const hide = items.includes(item)
item.classList.toggle(CLASS_NAME_HIDDEN, hide)
if (hide) {
item.dataset.bsNavOverflow = 'true'
} else {
delete item.dataset.bsNavOverflow
}
}
overflowItem?.classList.toggle(CLASS_NAME_HIDDEN, hideToggle)
}
_applyOverflow(items: HTMLElement[], overflowItem: HTMLElement | null): void {
const hasSubmenu = this._moveToOverflow(items)
this._setHidden(this._overflowItems, overflowItem)
if (this._overflowToggle && this._ownsToggle) {
Dropdown.getOrCreateInstance(this._overflowToggle, {
autoClose: hasSubmenu ? 'outside' : true,
popperConfig: { strategy: this._config.menuStrategy },
})
}
if (items.length > 0) {
EventHandler.trigger(this._element, EVENT_OVERFLOW, {
overflowCount: items.length,
visibleCount: this._items.length - items.length,
})
}
}
_moveToOverflow(items: HTMLElement[]): boolean {
let hasSubmenu = false
if (!this._overflowMenu) {
return hasSubmenu
}
this._clearMenu()
for (const item of items) {
const link = SelectorEngine.findOne(SELECTOR_NAV_LINK, item)
if (!link) {
continue
}
const menu = this._findItemMenu(item, link)
if (menu && link.matches(SELECTOR_MENU_TOGGLE)) {
this._overflowMenu.append(this._relocateAsSubmenu(item, link, menu))
hasSubmenu = true
} else {
this._overflowMenu.append(this._cloneAsProxy(link))
}
this._overflowItems.push(item)
}
return hasSubmenu
}
// Move the original menu (not a clone) so nested dropdowns, ids and state stay on one element
_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,
})
// An end-aligned menu sits at the inline end, so its submenus open towards the start
const submenu = document.createElement('div')
submenu.className = this._overflowMenu?.classList.contains(CLASS_NAME_MENU_END) ? CLASS_NAME_SUBMENU_START : CLASS_NAME_SUBMENU
submenu.append(this._cloneAsMenuItem(link, true), menu)
return submenu
}
// A link that drives a plugin, like a tab, acts through the original, which holds the state
_cloneAsProxy(link: HTMLElement): HTMLElement {
const clonedLink = this._cloneAsMenuItem(link)
if (link.matches(SELECTOR_ANY_TOGGLE)) {
this._removeDataAttributes(clonedLink)
EventHandler.on(clonedLink, EVENT_CLICK, (event: Event) => {
event.preventDefault()
link.click()
this._syncStates()
})
}
return clonedLink
}
_removeDataAttributes(element: HTMLElement): void {
for (const name of element.getAttributeNames()) {
if ((name.startsWith('data-bs-') || name.startsWith('data-tblr-')) && !name.endsWith('-theme')) {
element.removeAttribute(name)
}
}
}
_isActive(link: HTMLElement): boolean {
return link.classList.contains(CLASS_NAME_ACTIVE) || Boolean(link.closest(SELECTOR_NAV_ITEM)?.classList.contains(CLASS_NAME_ACTIVE))
}
_syncStates(): void {
for (const [clonedLink, link] of this._clones) {
clonedLink.classList.toggle(CLASS_NAME_ACTIVE, this._isActive(link))
}
}
_cloneAsMenuItem(link: HTMLElement, submenu = false): HTMLElement {
const clonedLink = link.cloneNode(true) as HTMLElement
clonedLink.className = 'dropdown-item'
clonedLink.removeAttribute('id')
this._clones.set(clonedLink, link)
if (this._isActive(link)) {
clonedLink.classList.add(CLASS_NAME_ACTIVE)
}
if (link.classList.contains('disabled') || link.hasAttribute('disabled')) {
clonedLink.classList.add('disabled')
}
if (submenu) {
this._removeDataAttributes(clonedLink)
clonedLink.classList.add('dropdown-toggle')
clonedLink.removeAttribute('href')
clonedLink.setAttribute('data-bs-toggle', 'dropdown')
clonedLink.setAttribute('data-bs-auto-close', 'outside')
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
}
_findItemMenu(item: HTMLElement, link: HTMLElement): HTMLElement | null {
const sibling = SelectorEngine.next(link, SELECTOR_MENU)[0]
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
}
_resetMenu(toggle: HTMLElement, menu: HTMLElement): void {
Dropdown.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)
}
_restoreRelocatedMenus(): void {
for (const { menu, parent, nextSibling } of this._relocatedMenus.values()) {
const toggle = menu.previousElementSibling as HTMLElement | null
if (toggle?.matches(SELECTOR_MENU_TOGGLE)) {
this._resetMenu(toggle, menu)
}
if (nextSibling) {
nextSibling.before(menu)
} else {
parent.append(menu)
}
}
this._relocatedMenus.clear()
}
_restoreItems(): void {
if (this._overflowToggle && this._overflowMenu) {
this._resetMenu(this._overflowToggle, this._overflowMenu)
}
this._restoreRelocatedMenus()
this._setHidden([], this._overflowToggle?.closest<HTMLElement>(SELECTOR_NAV_ITEM) ?? null, false)
this._clearMenu()
}
_clearMenu(): void {
for (const clonedLink of this._clones.keys()) {
EventHandler.off(clonedLink, EVENT_KEY)
}
this._clones.clear()
this._overflowMenu?.replaceChildren()
this._overflowItems = []
}
// Static
static jQueryInterface(this: JQueryCollectionLike, config?: unknown): unknown {
return this.each(function (this: HTMLElement) {
const data = NavOverflow.getOrCreateInstance(this, config as BaseComponentConfig) as unknown as Record<string, (arg?: unknown) => unknown>
if (typeof config !== 'string') {
return
}
if (data[config] === undefined || config.startsWith('_') || config === 'constructor') {
throw new TypeError(`No method named "${config}"`)
}
data[config]()
})
}
}
/**
* Data API implementation
*/
// js-docs-start nav-overflow-init
onDOMContentLoaded(() => {
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) {
NavOverflow.getOrCreateInstance(element)
}
})
// js-docs-end nav-overflow-init
/**
* jQuery
*/
defineJQueryPlugin(NavOverflow)
export default NavOverflow
+16
View File
@@ -47,6 +47,22 @@ export const DefaultAllowlist: AllowList = {
}
// js-docs-end allow-list
// js-docs-start icon-allow-list
export const DefaultIconAllowlist: AllowList = {
'*': ['class', 'role', ARIA_ATTRIBUTE_PATTERN],
'svg': ['xmlns', 'width', 'height', 'viewbox', 'fill', 'stroke', 'stroke-width', 'stroke-linecap', 'stroke-linejoin', 'focusable'],
'path': ['d', 'fill', 'stroke', 'stroke-width', 'fill-rule', 'clip-rule'],
'line': ['x1', 'y1', 'x2', 'y2', 'stroke', 'stroke-width', 'stroke-linecap'],
'circle': ['cx', 'cy', 'r', 'fill', 'stroke', 'stroke-width'],
'rect': ['x', 'y', 'width', 'height', 'rx', 'ry', 'fill', 'stroke', 'stroke-width'],
'polyline': ['points', 'fill', 'stroke', 'stroke-width'],
'polygon': ['points', 'fill', 'stroke', 'stroke-width'],
'g': ['fill', 'stroke', 'stroke-width', 'transform'],
'span': [],
'i': [],
}
// js-docs-end icon-allow-list
const uriAttributes = new Set(['background', 'cite', 'href', 'itemtype', 'longdesc', 'poster', 'src', 'xlink:href'])
const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i
+554
View File
@@ -0,0 +1,554 @@
import { describe, it, expect, beforeAll, afterEach, vi } from 'vitest'
import NavOverflow from '../../src/bootstrap/nav-overflow'
import Dropdown from '../../src/bootstrap/dropdown'
import { clearFixture, getFixture } from '../helpers/fixture'
vi.mock('@popperjs/core', () => ({
createPopper: vi.fn(() => ({
destroy: vi.fn(),
update: vi.fn(),
setOptions: vi.fn(),
})),
}))
const STYLE = `<style>
.nav, .navbar-nav { display: flex; margin: 0; padding: 0; list-style: none; }
.nav-item { flex-shrink: 0; width: 100px; }
.nav-overflow-item { width: 50px; }
.d-none { display: none !important; }
.dropdown-menu { display: none; }
.dropdown-menu.show { display: block; }
</style>`
const links = (count: number, extra: Record<number, string> = {}): string => Array.from({ length: count }, (_, index) => extra[index] ?? `<li class="nav-item"><a class="nav-link" href="#">Link ${index + 1}</a></li>`).join('')
const nav = (width: number, items: string, attributes = ''): string => `${STYLE}<div class="nav-overflow" style="width: ${width}px" ${attributes}><ul class="nav">${items}</ul></div>`
describe('NavOverflow', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
afterEach(() => {
clearFixture()
})
const wrapper = (): HTMLElement => fixtureEl.querySelector('.nav-overflow')!
const hidden = (): HTMLElement[] => [...fixtureEl.querySelectorAll<HTMLElement>('.nav-overflow > ul > .nav-item[data-bs-nav-overflow="true"]')]
const menuItems = (): HTMLElement[] => [...fixtureEl.querySelectorAll<HTMLElement>('.nav-overflow-menu > .dropdown-item')]
const toggleItem = (): HTMLElement => fixtureEl.querySelector('.nav-overflow-item')!
describe('Default', () => {
it('should return plugin default config', () => {
expect(NavOverflow.Default).toEqual(
expect.objectContaining({
collapseBelow: 0,
iconPlacement: 'start',
menuPlacement: 'bottom-end',
moreText: 'More',
threshold: 0,
}),
)
})
})
describe('DATA_KEY', () => {
it('should return plugin data key', () => {
expect(NavOverflow.DATA_KEY).toEqual('bs.navoverflow')
})
})
describe('constructor', () => {
it('should accept a selector or an element', () => {
fixtureEl.innerHTML = nav(1000, links(3))
const bySelector = new NavOverflow('.nav-overflow')
expect(bySelector._element).toEqual(wrapper())
bySelector.dispose()
const byElement = new NavOverflow(wrapper())
expect(byElement._element).toEqual(wrapper())
})
it('should throw when the wrapper has no child nav', () => {
fixtureEl.innerHTML = '<div class="nav-overflow"></div>'
expect(() => new NavOverflow(wrapper())).toThrowError(TypeError)
})
it('should add the wrapper and initialized classes', () => {
fixtureEl.innerHTML = nav(1000, links(3)).replace('class="nav-overflow"', '')
const element = fixtureEl.querySelector<HTMLElement>('div')!
new NavOverflow(element)
expect(element).toHaveClass('nav-overflow')
expect(element).toHaveClass('nav-overflow-initialized')
})
it('should create a dropdown toggle and menu inside the nav', () => {
fixtureEl.innerHTML = nav(1000, links(3))
new NavOverflow(wrapper())
const toggle = fixtureEl.querySelector('.nav > .nav-overflow-item > .nav-overflow-toggle')!
expect(toggle.getAttribute('data-bs-toggle')).toEqual('dropdown')
expect(toggle.nextElementSibling).toHaveClass('dropdown-menu')
expect(toggle.nextElementSibling).toHaveClass('nav-overflow-menu')
expect(toggle.nextElementSibling).toHaveClass('dropdown-menu-end')
})
it('should add the wrapper class and remove it on dispose when it was missing', () => {
fixtureEl.innerHTML = `${STYLE}<div id="bare" style="width: 1000px"><ul class="nav">${links(3)}</ul></div>`
const bare = fixtureEl.querySelector<HTMLElement>('#bare')!
const navOverflow = new NavOverflow(bare)
expect(bare).toHaveClass('nav-overflow')
navOverflow.dispose()
expect(bare).not.toHaveClass('nav-overflow')
})
})
describe('overflow', () => {
it('should hide the toggle when every item fits', () => {
fixtureEl.innerHTML = nav(1000, links(5))
new NavOverflow(wrapper())
expect(hidden()).toHaveLength(0)
expect(toggleItem()).toHaveClass('d-none')
})
it('should not reserve room for the toggle when every item fits without it', () => {
fixtureEl.innerHTML = nav(520, links(5))
new NavOverflow(wrapper())
expect(hidden()).toHaveLength(0)
expect(toggleItem()).toHaveClass('d-none')
})
it('should move items that do not fit into the menu', () => {
fixtureEl.innerHTML = nav(360, links(6))
new NavOverflow(wrapper())
expect(hidden().map((item) => item.textContent)).toEqual(['Link 4', 'Link 5', 'Link 6'])
expect(menuItems().map((item) => item.textContent)).toEqual(['Link 4', 'Link 5', 'Link 6'])
expect(toggleItem()).not.toHaveClass('d-none')
})
it('should collapse a .navbar-nav', () => {
fixtureEl.innerHTML = nav(360, links(6)).replace('class="nav"', 'class="navbar-nav"')
new NavOverflow(wrapper())
expect(hidden()).toHaveLength(3)
})
it('should not collapse a vertical nav', () => {
fixtureEl.innerHTML = nav(360, links(6)).replace('class="nav"', 'class="nav" style="flex-direction: column"')
new NavOverflow(wrapper())
expect(hidden()).toHaveLength(0)
expect(toggleItem()).toHaveClass('d-none')
})
it('should copy the active state from the nav item', () => {
fixtureEl.innerHTML = nav(160, links(3, { 2: '<li class="nav-item active"><a class="nav-link" href="#">Current</a></li>' }))
new NavOverflow(wrapper())
expect(menuItems().at(-1)).toHaveClass('active')
})
it('should keep nav-overflow-keep items visible', () => {
fixtureEl.innerHTML = nav(360, links(6, { 5: '<li class="nav-item nav-overflow-keep"><a class="nav-link" href="#">Keep</a></li>' }))
new NavOverflow(wrapper())
expect(fixtureEl.querySelector('.nav-overflow-keep')).not.toHaveClass('d-none')
expect(menuItems().map((item) => item.textContent)).toEqual(['Link 3', 'Link 4', 'Link 5'])
})
it('should preserve active and disabled states', () => {
fixtureEl.innerHTML = nav(
160,
links(3, {
1: '<li class="nav-item"><a class="nav-link active" id="active" href="#">Active</a></li>',
2: '<li class="nav-item"><a class="nav-link disabled" aria-disabled="true">Disabled</a></li>',
}),
)
new NavOverflow(wrapper())
const [active, disabled] = menuItems()
expect(active).toHaveClass('dropdown-item')
expect(active).toHaveClass('active')
expect(active.hasAttribute('id')).toBe(false)
expect(disabled).toHaveClass('disabled')
})
it('should fire the overflow event with counts', () => {
fixtureEl.innerHTML = nav(360, links(6))
const listener = vi.fn()
wrapper().addEventListener('overflow.bs.navoverflow', listener)
new NavOverflow(wrapper())
const event = listener.mock.calls[0][0] as Event & { overflowCount: number; visibleCount: number }
expect(event.overflowCount).toEqual(3)
expect(event.visibleCount).toEqual(3)
})
it('should restore items when the wrapper grows', () => {
fixtureEl.innerHTML = nav(360, links(6))
const navOverflow = new NavOverflow(wrapper())
expect(hidden()).toHaveLength(3)
wrapper().style.width = '1000px'
navOverflow.update()
expect(hidden()).toHaveLength(0)
expect(menuItems()).toHaveLength(0)
})
it('should fire the update event', () => {
fixtureEl.innerHTML = nav(1000, links(3))
const listener = vi.fn()
wrapper().addEventListener('update.bs.navoverflow', listener)
new NavOverflow(wrapper()).update()
expect(listener).toHaveBeenCalledTimes(1)
})
it('should recalculate when the wrapper is resized', async () => {
fixtureEl.innerHTML = nav(1000, links(6))
new NavOverflow(wrapper())
expect(hidden()).toHaveLength(0)
wrapper().style.width = '360px'
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
expect(hidden()).toHaveLength(3)
})
it('should keep an open menu open when nothing moves', () => {
fixtureEl.innerHTML = nav(360, links(6))
const navOverflow = new NavOverflow(wrapper())
const toggle = fixtureEl.querySelector<HTMLElement>('.nav-overflow-toggle')!
toggle.click()
expect(fixtureEl.querySelector('.nav-overflow-menu')).toHaveClass('show')
wrapper().style.height = '200px'
wrapper().style.width = '365px'
navOverflow.update()
expect(fixtureEl.querySelector('.nav-overflow-menu')).toHaveClass('show')
expect(toggle.getAttribute('aria-expanded')).toEqual('true')
expect(hidden()).toHaveLength(3)
expect(menuItems()).toHaveLength(3)
})
it('should rebuild the menu when the overflowing items change', () => {
fixtureEl.innerHTML = nav(360, links(6))
const navOverflow = new NavOverflow(wrapper())
fixtureEl.querySelector<HTMLElement>('.nav-overflow-toggle')!.click()
wrapper().style.width = '460px'
navOverflow.update()
expect(fixtureEl.querySelector('.nav-overflow-menu')).not.toHaveClass('show')
expect(menuItems().map((item) => item.textContent)).toEqual(['Link 5', 'Link 6'])
})
it('should reuse a toggle written in the markup', () => {
fixtureEl.innerHTML = nav(360, links(6) + '<li class="nav-item dropdown nav-overflow-item"><button class="nav-link nav-overflow-toggle" type="button" data-bs-toggle="dropdown" aria-label="Show more">…</button><div class="dropdown-menu nav-overflow-menu"></div></li>')
const navOverflow = new NavOverflow(wrapper())
expect(fixtureEl.querySelectorAll('.nav-overflow-toggle')).toHaveLength(1)
expect(menuItems()).toHaveLength(3)
navOverflow.dispose()
expect(fixtureEl.querySelector('.nav-overflow-toggle')).not.toBeNull()
})
})
describe('config', () => {
it('should keep a minimum number of visible items with threshold', () => {
fixtureEl.innerHTML = nav(100, links(6))
new NavOverflow(wrapper(), { threshold: 2 })
expect(hidden()).toHaveLength(4)
})
it('should drop the text and label the toggle when moreText is false', () => {
fixtureEl.innerHTML = nav(1000, links(3))
new NavOverflow(wrapper(), { moreText: false })
const toggle = fixtureEl.querySelector('.nav-overflow-toggle')!
expect(toggle.querySelector('.nav-overflow-text')).toBeNull()
expect(toggle.getAttribute('aria-label')).toEqual('More')
})
it('should read moreText from data attributes', () => {
fixtureEl.innerHTML = nav(1000, links(3), 'data-bs-more-text="See all"')
new NavOverflow(wrapper())
const toggle = fixtureEl.querySelector('.nav-overflow-toggle')!
expect(toggle.querySelector('.nav-overflow-text')!.textContent).toEqual('See all')
expect(toggle.hasAttribute('aria-label')).toBe(false)
})
it('should treat moreText as plain text', () => {
fixtureEl.innerHTML = nav(1000, links(3))
new NavOverflow(wrapper(), { moreText: '<img src=x onerror=alert(1)>' })
expect(fixtureEl.querySelector('.nav-overflow-text img')).toBeNull()
})
it('should place the icon after the text when iconPlacement is end', () => {
fixtureEl.innerHTML = nav(1000, links(3))
new NavOverflow(wrapper(), { iconPlacement: 'end' })
const toggle = fixtureEl.querySelector('.nav-overflow-toggle')!
expect(toggle.firstElementChild).toHaveClass('nav-overflow-text')
expect(toggle.lastElementChild).toHaveClass('nav-overflow-icon')
})
it('should sanitize moreIcon', () => {
fixtureEl.innerHTML = nav(1000, links(3))
new NavOverflow(wrapper(), { moreIcon: '<svg viewBox="0 0 24 24" onload="alert(1)"><path d="M0 0"/></svg><script>alert(1)</script>' })
const icon = fixtureEl.querySelector('.nav-overflow-icon')!
expect(icon.querySelector('script')).toBeNull()
expect(icon.querySelector('svg')!.hasAttribute('onload')).toBe(false)
expect(icon.querySelector('svg')!.getAttribute('viewBox')).toEqual('0 0 24 24')
})
it('should use a child [data-bs-overflow-icon] element as the icon', () => {
fixtureEl.innerHTML = nav(1000, links(3)).replace('</ul>', '</ul><svg data-bs-overflow-icon class="custom-icon" onclick="alert(1)"></svg>')
new NavOverflow(wrapper(), { moreIcon: '<svg class="config-icon"></svg>' })
const icon = fixtureEl.querySelector('.nav-overflow-icon > svg')!
expect(icon).toHaveClass('custom-icon')
expect(icon.hasAttribute('data-bs-overflow-icon')).toBe(false)
expect(icon.hasAttribute('onclick')).toBe(false)
expect(fixtureEl.querySelectorAll('.custom-icon')).toHaveLength(1)
})
it('should map menuPlacement to dropdown classes', () => {
fixtureEl.innerHTML = nav(1000, links(3))
new NavOverflow(wrapper(), { menuPlacement: 'top-start' })
expect(toggleItem()).toHaveClass('dropup')
expect(fixtureEl.querySelector('.nav-overflow-menu')).not.toHaveClass('dropdown-menu-end')
})
it('should pass menuStrategy to the overflow dropdown', () => {
fixtureEl.innerHTML = nav(360, links(6))
new NavOverflow(wrapper(), { menuStrategy: 'fixed' })
const dropdown = Dropdown.getInstance(fixtureEl.querySelector<HTMLElement>('.nav-overflow-toggle')!) as Dropdown
expect(dropdown._config.popperConfig).toEqual({ strategy: 'fixed' })
})
})
describe('collapseBelow', () => {
it('should collapse every item below a pixel value', () => {
fixtureEl.innerHTML = nav(600, links(3))
new NavOverflow(wrapper(), { collapseBelow: 700 })
expect(hidden()).toHaveLength(3)
})
it('should not collapse above a pixel value', () => {
fixtureEl.innerHTML = nav(600, links(3))
new NavOverflow(wrapper(), { collapseBelow: 500 })
expect(hidden()).toHaveLength(0)
})
it('should resolve a breakpoint name from --tblr-breakpoint-{name}', () => {
document.documentElement.style.setProperty('--tblr-breakpoint-md', '768px')
fixtureEl.innerHTML = nav(600, links(3), 'data-bs-collapse-below="md"')
new NavOverflow(wrapper())
expect(hidden()).toHaveLength(3)
document.documentElement.style.removeProperty('--tblr-breakpoint-md')
})
it('should keep nav-overflow-keep items when collapsing all', () => {
fixtureEl.innerHTML = nav(600, links(3, { 0: '<li class="nav-item nav-overflow-keep"><a class="nav-link" href="#">Keep</a></li>' }))
new NavOverflow(wrapper(), { collapseBelow: 700 })
expect(hidden()).toHaveLength(2)
})
})
describe('dropdowns', () => {
const DROPDOWN_ITEM = '<li class="nav-item dropdown"><a class="nav-link dropdown-toggle" id="products" href="#" data-bs-toggle="dropdown" aria-expanded="false">Products</a><div class="dropdown-menu" id="products-menu"><a class="dropdown-item" href="#">Laptops</a></div></li>'
it('should move an overflowing dropdown into the menu as a nested dropstart', () => {
fixtureEl.innerHTML = nav(260, links(4, { 3: DROPDOWN_ITEM }))
new NavOverflow(wrapper())
const submenu = fixtureEl.querySelector('.nav-overflow-menu > .dropstart')!
const toggle = submenu.firstElementChild as HTMLElement
expect(toggle).toHaveClass('dropdown-item')
expect(toggle).toHaveClass('dropdown-toggle')
expect(toggle.getAttribute('data-bs-toggle')).toEqual('dropdown')
expect(toggle.getAttribute('data-bs-auto-close')).toEqual('outside')
expect(toggle.getAttribute('role')).toEqual('button')
expect(toggle.hasAttribute('href')).toBe(false)
expect(submenu.querySelector('#products-menu')).not.toBeNull()
const overflow = Dropdown.getInstance(fixtureEl.querySelector<HTMLElement>('.nav-overflow-toggle')!) as Dropdown
expect(overflow._config.autoClose).toEqual('outside')
})
it('should open submenus towards the end when the menu is start-aligned', () => {
fixtureEl.innerHTML = nav(260, links(4, { 3: DROPDOWN_ITEM }))
new NavOverflow(wrapper(), { menuPlacement: 'bottom-start' })
expect(fixtureEl.querySelector('.nav-overflow-menu > .dropend')).not.toBeNull()
expect(fixtureEl.querySelector('.nav-overflow-menu > .dropstart')).toBeNull()
})
it('should open the nested dropdown from the overflow menu', () => {
fixtureEl.innerHTML = nav(260, links(4, { 3: DROPDOWN_ITEM }))
new NavOverflow(wrapper())
const toggle = fixtureEl.querySelector<HTMLElement>('.nav-overflow-menu > .dropstart > .dropdown-toggle')!
toggle.click()
expect(fixtureEl.querySelector('#products-menu')).toHaveClass('show')
})
it('should move the menu back to its nav item on restore', () => {
fixtureEl.innerHTML = nav(260, links(4, { 3: DROPDOWN_ITEM }))
const navOverflow = new NavOverflow(wrapper())
wrapper().style.width = '1000px'
navOverflow.update()
const menu = fixtureEl.querySelector('#products-menu')!
expect(menu.previousElementSibling!.id).toEqual('products')
expect(fixtureEl.querySelector('.nav-overflow-menu .dropstart')).toBeNull()
})
})
describe('proxy links', () => {
const tabs = (): string => links(6, { 5: '<li class="nav-item"><a class="nav-link" id="last" href="#pane" data-bs-toggle="tab">Last</a></li>' })
it('should send a click on a moved plugin link to the original', () => {
fixtureEl.innerHTML = nav(360, tabs())
new NavOverflow(wrapper())
const original = fixtureEl.querySelector<HTMLElement>('#last')!
const spy = vi.fn()
original.addEventListener('click', spy)
const cloned = menuItems().at(-1)!
expect(cloned.hasAttribute('data-bs-toggle')).toBe(false)
cloned.click()
expect(spy).toHaveBeenCalledTimes(1)
})
it('should copy the active state to the menu after the click', () => {
fixtureEl.innerHTML = nav(360, tabs())
new NavOverflow(wrapper())
const original = fixtureEl.querySelector<HTMLElement>('#last')!
original.addEventListener('click', () => original.classList.add('active'))
const cloned = menuItems().at(-1)!
cloned.click()
expect(cloned).toHaveClass('active')
})
it('should keep plain links as they are', () => {
fixtureEl.innerHTML = nav(360, links(6))
new NavOverflow(wrapper())
expect(menuItems().at(-1)!.getAttribute('href')).toEqual('#')
})
})
describe('jQueryInterface', () => {
it('should create an instance and call a method', () => {
fixtureEl.innerHTML = nav(1000, links(3))
const element = wrapper()
const collection = { each: (callback: (this: HTMLElement) => void) => callback.call(element) }
NavOverflow.jQueryInterface.call(collection as never, 'update')
expect(NavOverflow.getInstance(element)).not.toBeNull()
expect(() => NavOverflow.jQueryInterface.call(collection as never, 'nope')).toThrow(TypeError)
})
})
describe('dispose', () => {
it('should restore items and remove the generated toggle', () => {
fixtureEl.innerHTML = nav(360, links(6))
const navOverflow = new NavOverflow(wrapper())
navOverflow.dispose()
expect(hidden()).toHaveLength(0)
expect(fixtureEl.querySelector('.d-none')).toBeNull()
expect(fixtureEl.querySelector('.nav-overflow-item')).toBeNull()
expect(NavOverflow.getInstance(wrapper())).toBeNull()
})
it('should disconnect the ResizeObserver', () => {
fixtureEl.innerHTML = nav(1000, links(3))
const navOverflow = new NavOverflow(wrapper())
const spy = vi.spyOn(navOverflow._resizeObserver!, 'disconnect')
navOverflow.dispose()
expect(spy).toHaveBeenCalled()
})
})
describe('getOrCreateInstance', () => {
it('should return the existing instance', () => {
fixtureEl.innerHTML = nav(1000, links(3))
const navOverflow = new NavOverflow(wrapper())
expect(NavOverflow.getOrCreateInstance(wrapper())).toEqual(navOverflow)
})
})
})
+4 -4
View File
@@ -212,19 +212,19 @@
},
{
"path": "./dist/js/tabler.js",
"maxSize": "59 kB"
"maxSize": "64 kB"
},
{
"path": "./dist/js/tabler.min.js",
"maxSize": "40 kB"
"maxSize": "42 kB"
},
{
"path": "./dist/js/tabler.esm.js",
"maxSize": "59 kB"
"maxSize": "64 kB"
},
{
"path": "./dist/js/tabler.esm.min.js",
"maxSize": "40 kB"
"maxSize": "42 kB"
},
{
"path": "./dist/js/tabler-theme.js",
+1
View File
@@ -77,6 +77,7 @@
@forward 'ui/login';
@forward 'ui/modals';
@forward 'ui/nav';
@forward 'ui/nav-overflow';
@forward 'ui/stars';
@forward 'ui/pagination';
@forward 'ui/popovers';
+41
View File
@@ -0,0 +1,41 @@
@use '../config' as *;
// The wrapper is measured, so it must never size to the nav inside it
.nav-overflow {
contain: inline-size;
flex: 1 1 0;
min-width: 0;
// Clip instead of hiding, so the nav stays usable when the script does not run
&:not(.nav-overflow-initialized) {
overflow: clip;
}
> .nav,
> .navbar-nav {
display: flex;
flex-wrap: nowrap;
min-width: 0;
> .nav-item {
flex-shrink: 0;
}
}
}
.nav-overflow-item {
margin-inline-start: auto;
}
.nav-overflow-toggle {
gap: var(--nav-link-icon-margin-end);
}
.nav-overflow-icon {
display: flex;
.icon {
width: var(--nav-link-icon-width, #{$nav-link-icon-size});
height: var(--nav-link-icon-height, #{$nav-link-icon-size});
}
}