mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 05:06:55 +04:00
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:
16 files changed
+1914
-7
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user