mirror of
https://github.com/tabler/tabler.git
synced 2026-10-12 13:17:00 +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
@@ -22,6 +22,7 @@ js-select-illustration
|
|||||||
js-select-skin-color
|
js-select-skin-color
|
||||||
line
|
line
|
||||||
list
|
list
|
||||||
|
nav-overflow-keep
|
||||||
settings
|
settings
|
||||||
slide
|
slide
|
||||||
sort-category
|
sort-category
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@tabler/core": minor
|
||||||
|
"@tabler/preview": minor
|
||||||
|
"@tabler/docs": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Added `NavOverflow` component that moves nav items that do not fit into a More dropdown, with a `.nav-overflow` wrapper.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@tabler/preview": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Updated the preview navbar to move menu items that do not fit into a More dropdown with `.nav-overflow`.
|
||||||
@@ -6,6 +6,7 @@ export { default as Carousel } from './bootstrap/carousel'
|
|||||||
export { default as Collapse } from './bootstrap/collapse'
|
export { default as Collapse } from './bootstrap/collapse'
|
||||||
export { default as Dropdown } from './bootstrap/dropdown'
|
export { default as Dropdown } from './bootstrap/dropdown'
|
||||||
export { default as Modal } from './bootstrap/modal'
|
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 Offcanvas } from './bootstrap/offcanvas'
|
||||||
export { default as Popover } from './bootstrap/popover'
|
export { default as Popover } from './bootstrap/popover'
|
||||||
export { default as ScrollSpy } from './bootstrap/scrollspy'
|
export { default as ScrollSpy } from './bootstrap/scrollspy'
|
||||||
@@ -19,6 +20,7 @@ import Carousel from './bootstrap/carousel'
|
|||||||
import Collapse from './bootstrap/collapse'
|
import Collapse from './bootstrap/collapse'
|
||||||
import Dropdown from './bootstrap/dropdown'
|
import Dropdown from './bootstrap/dropdown'
|
||||||
import Modal from './bootstrap/modal'
|
import Modal from './bootstrap/modal'
|
||||||
|
import NavOverflow from './bootstrap/nav-overflow'
|
||||||
import Offcanvas from './bootstrap/offcanvas'
|
import Offcanvas from './bootstrap/offcanvas'
|
||||||
import Popover from './bootstrap/popover'
|
import Popover from './bootstrap/popover'
|
||||||
import ScrollSpy from './bootstrap/scrollspy'
|
import ScrollSpy from './bootstrap/scrollspy'
|
||||||
@@ -33,6 +35,7 @@ export const bootstrap = {
|
|||||||
Collapse,
|
Collapse,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Modal,
|
Modal,
|
||||||
|
NavOverflow,
|
||||||
Offcanvas,
|
Offcanvas,
|
||||||
Popover,
|
Popover,
|
||||||
ScrollSpy,
|
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-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 uriAttributes = new Set(['background', 'cite', 'href', 'itemtype', 'longdesc', 'poster', 'src', 'xlink:href'])
|
||||||
|
|
||||||
const SAFE_URL_PATTERN = /^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i
|
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",
|
"path": "./dist/js/tabler.js",
|
||||||
"maxSize": "59 kB"
|
"maxSize": "64 kB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/js/tabler.min.js",
|
"path": "./dist/js/tabler.min.js",
|
||||||
"maxSize": "40 kB"
|
"maxSize": "42 kB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/js/tabler.esm.js",
|
"path": "./dist/js/tabler.esm.js",
|
||||||
"maxSize": "59 kB"
|
"maxSize": "64 kB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/js/tabler.esm.min.js",
|
"path": "./dist/js/tabler.esm.min.js",
|
||||||
"maxSize": "40 kB"
|
"maxSize": "42 kB"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "./dist/js/tabler-theme.js",
|
"path": "./dist/js/tabler-theme.js",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
@forward 'ui/login';
|
@forward 'ui/login';
|
||||||
@forward 'ui/modals';
|
@forward 'ui/modals';
|
||||||
@forward 'ui/nav';
|
@forward 'ui/nav';
|
||||||
|
@forward 'ui/nav-overflow';
|
||||||
@forward 'ui/stars';
|
@forward 'ui/stars';
|
||||||
@forward 'ui/pagination';
|
@forward 'ui/pagination';
|
||||||
@forward 'ui/popovers';
|
@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});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
---
|
||||||
|
title: Nav overflow
|
||||||
|
summary: Nav overflow keeps a nav on one line. Items that do not fit move into a More dropdown, and come back when there is room again.
|
||||||
|
description: Keep a nav on one line by moving the items that do not fit into a More dropdown.
|
||||||
|
added-in: '1.7.0'
|
||||||
|
related: [/ui/layout/navs-tabs, /ui/components/dropdown]
|
||||||
|
source: core/scss/ui/_nav-overflow.scss
|
||||||
|
classnames:
|
||||||
|
component:
|
||||||
|
- class: nav-overflow
|
||||||
|
desc: Wrapper around the nav. The component measures this element to decide what fits
|
||||||
|
items:
|
||||||
|
- class: nav-overflow-keep
|
||||||
|
desc: Put on a nav item that must never move to the menu
|
||||||
|
toggle:
|
||||||
|
- class: nav-overflow-item
|
||||||
|
desc: The nav item that holds the More toggle and its menu
|
||||||
|
- class: nav-overflow-toggle
|
||||||
|
desc: The More button. Write your own to change its markup or label
|
||||||
|
- class: nav-overflow-menu
|
||||||
|
desc: The dropdown menu that receives the moved items
|
||||||
|
- class: nav-overflow-icon
|
||||||
|
desc: Wrapper around the toggle icon
|
||||||
|
- class: nav-overflow-text
|
||||||
|
desc: Wrapper around the toggle text
|
||||||
|
---
|
||||||
|
import Example from '@components/Example.astro';
|
||||||
|
import CodeDocs from '@components/CodeDocs.astro';
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A nav with many links wraps to a second line, or runs out of its container, when the screen gets narrow. Nav overflow keeps the nav on one line. Items that do not fit move into a More dropdown at the end of the nav, and come back when there is room again. This is also known as the Priority+ pattern.
|
||||||
|
|
||||||
|
Wrap the `.nav` in a `.nav-overflow` element and add `data-bs-toggle="nav-overflow"` to the wrapper. Resize the window to see the items move.
|
||||||
|
|
||||||
|
<Example overflow="visible">
|
||||||
|
<div class="nav-overflow" data-bs-toggle="nav-overflow">
|
||||||
|
<ul class="nav nav-pills">
|
||||||
|
<li class="nav-item"><a class="nav-link active" aria-current="page" href="#">Home</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Dashboard</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Products</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Services</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Analytics</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Reports</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Settings</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Help</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Example>
|
||||||
|
|
||||||
|
How it works:
|
||||||
|
|
||||||
|
- The component watches the size of the wrapper, not of the window. It works in a card, a sidebar or any container that changes width.
|
||||||
|
- It measures the wrapper, not the nav. Moving items changes the width of the nav, so measuring the nav would feed the result back in.
|
||||||
|
- Moved items are copied into the menu as `.dropdown-item`, and the originals are hidden. The `.active` and `.disabled` states are copied too.
|
||||||
|
- A link with `data-bs-toggle`, like a tab, works from the menu. The copy sends the click to the original link, and the `.active` state follows.
|
||||||
|
- Until the script runs, the nav is clipped to one line but stays visible, so it still works without JavaScript.
|
||||||
|
- It works with every nav style: plain, `.nav-pills`, `.nav-tabs` and `.nav-underline`.
|
||||||
|
- It also works with the `.navbar-nav` of a navbar. A vertical nav, like the collapsed navbar on a phone, keeps every item visible.
|
||||||
|
|
||||||
|
## Tabs
|
||||||
|
|
||||||
|
<Example overflow="visible">
|
||||||
|
<div class="nav-overflow" data-bs-toggle="nav-overflow">
|
||||||
|
<ul class="nav nav-tabs">
|
||||||
|
<li class="nav-item"><a class="nav-link active" aria-current="page" href="#">Overview</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Details</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">History</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Activity</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link disabled" aria-disabled="true">Comments</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Attachments</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Related</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Settings</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Example>
|
||||||
|
|
||||||
|
## Keep items visible
|
||||||
|
|
||||||
|
Add `.nav-overflow-keep` to an item that must never move to the menu, such as the home link or a call to action. The other items move first.
|
||||||
|
|
||||||
|
<Example overflow="visible">
|
||||||
|
<div class="nav-overflow" data-bs-toggle="nav-overflow">
|
||||||
|
<ul class="nav nav-underline">
|
||||||
|
<li class="nav-item nav-overflow-keep"><a class="nav-link active" aria-current="page" href="#">Home</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Products</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Services</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">About</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Blog</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Careers</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Partners</a></li>
|
||||||
|
<li class="nav-item nav-overflow-keep"><a class="nav-link" href="#">Contact</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Example>
|
||||||
|
|
||||||
|
## With dropdowns
|
||||||
|
|
||||||
|
A nav item that has its own dropdown becomes a nested dropdown in the More menu. It opens towards the start (`.dropstart`) when the menu is aligned to the end, which is the default, and towards the end (`.dropend`) otherwise. The component moves the original `.dropdown-menu` instead of copying it, so its ids and content stay the same, and moves it back when the item fits again.
|
||||||
|
|
||||||
|
<Example overflow="visible">
|
||||||
|
<div class="nav-overflow" data-bs-toggle="nav-overflow">
|
||||||
|
<ul class="nav nav-pills">
|
||||||
|
<li class="nav-item"><a class="nav-link active" aria-current="page" href="#">Home</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Dashboard</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Services</a></li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" role="button" aria-expanded="false">Products</a>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<a class="dropdown-item" href="#">Laptops</a>
|
||||||
|
<a class="dropdown-item" href="#">Phones</a>
|
||||||
|
<a class="dropdown-item" href="#">Tablets</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" role="button" aria-expanded="false">Company</a>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<a class="dropdown-item" href="#">About</a>
|
||||||
|
<a class="dropdown-item" href="#">Careers</a>
|
||||||
|
<a class="dropdown-item" href="#">Press</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Reports</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Help</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Example>
|
||||||
|
|
||||||
|
## Customizing the toggle
|
||||||
|
|
||||||
|
### Text and icon
|
||||||
|
|
||||||
|
Change the text with `data-bs-more-text`. Set it to `false` for an icon-only toggle. The button then gets `aria-label="More"`, so it still has a name.
|
||||||
|
|
||||||
|
<Example overflow="visible">
|
||||||
|
<div class="nav-overflow" data-bs-toggle="nav-overflow" data-bs-more-text="false">
|
||||||
|
<ul class="nav nav-pills">
|
||||||
|
<li class="nav-item"><a class="nav-link active" aria-current="page" href="#">Home</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Dashboard</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Products</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Services</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Analytics</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Reports</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Settings</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Help</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Example>
|
||||||
|
|
||||||
|
To use another icon, put an element with `data-bs-overflow-icon` inside the wrapper, next to the nav. The component uses it as the toggle icon and removes the original. The icon markup is sanitized first.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="nav-overflow" data-bs-toggle="nav-overflow">
|
||||||
|
<ul class="nav">…</ul>
|
||||||
|
<svg data-bs-overflow-icon 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 6l16 0" /><path d="M4 12l16 0" /><path d="M4 18l16 0" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Your own toggle
|
||||||
|
|
||||||
|
To change the markup or the label of the button, write the toggle yourself. The component uses a `.nav-overflow-toggle` it finds in the wrapper instead of creating one:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="nav-overflow" data-bs-toggle="nav-overflow">
|
||||||
|
<ul class="nav nav-pills">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>
|
||||||
|
…
|
||||||
|
<li class="nav-item dropdown nav-overflow-item">
|
||||||
|
<button class="nav-link nav-overflow-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Show more links">…</button>
|
||||||
|
<div class="dropdown-menu dropdown-menu-end nav-overflow-menu"></div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Collapse all
|
||||||
|
|
||||||
|
Use `data-bs-collapse-below` to move every item into the menu when the wrapper is narrower than a breakpoint. Pass a breakpoint name, read from `--tblr-breakpoint-{name}`, or a number of pixels. Above that width the nav collapses item by item again. Items with `.nav-overflow-keep` stay visible.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="nav-overflow" data-bs-toggle="nav-overflow" data-bs-collapse-below="md">…</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
Pass options as data attributes on the wrapper, or in the config object.
|
||||||
|
|
||||||
|
| Option | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `collapseBelow` | `0` | Wrapper width below which every item moves to the menu. A breakpoint name (`md`) or a number of pixels. `0` turns it off. |
|
||||||
|
| `iconPlacement` | `'start'` | Puts the icon before (`start`) or after (`end`) the text. |
|
||||||
|
| `menuPlacement` | `'bottom-end'` | Where the menu opens: `bottom-end`, `bottom-start`, `top-end` or `top-start`. |
|
||||||
|
| `menuStrategy` | `'absolute'` | Positioning strategy of the menu. Use `fixed` when a parent with `overflow` clips it. |
|
||||||
|
| `moreText` | `'More'` | Text of the toggle, inserted as plain text. `false` or an empty string gives an icon-only toggle. |
|
||||||
|
| `moreIcon` | three dots | SVG or HTML of the toggle icon. It is sanitized before insertion. |
|
||||||
|
| `threshold` | `0` | Minimum number of items that stay visible. |
|
||||||
|
|
||||||
|
## JavaScript
|
||||||
|
|
||||||
|
On page load Tabler creates a `NavOverflow` for every element with `data-bs-toggle="nav-overflow"`. This is the code that runs:
|
||||||
|
|
||||||
|
<CodeDocs name="nav-overflow-init" file="core/js/src/bootstrap/nav-overflow.ts" />
|
||||||
|
|
||||||
|
Create it yourself for a nav added later, and pass the wrapper, not the nav:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const navOverflow = new tabler.NavOverflow(document.querySelector('.nav-overflow'), {
|
||||||
|
moreText: 'See all',
|
||||||
|
threshold: 2,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
| Method | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `update()` | Works out again which items fit. It runs by itself when the wrapper changes size. |
|
||||||
|
| `dispose()` | Puts every item back, removes the generated toggle and stops watching the wrapper. |
|
||||||
|
| `getInstance(element)` | Static. Returns the component for the wrapper, or `null`. |
|
||||||
|
| `getOrCreateInstance(element, config)` | Static. Returns the component for the wrapper and creates it when needed. |
|
||||||
|
|
||||||
|
Both events fire on the wrapper:
|
||||||
|
|
||||||
|
| Event | Description |
|
||||||
|
| --- | --- |
|
||||||
|
| `update.bs.navoverflow` | Fires after `update()`. |
|
||||||
|
| `overflow.bs.navoverflow` | Fires when items move to the menu. The event carries `overflowCount` and `visibleCount`. |
|
||||||
|
|
||||||
|
```js
|
||||||
|
document.querySelector('.nav-overflow').addEventListener('overflow.bs.navoverflow', (event) => {
|
||||||
|
console.log(`${event.overflowCount} items in the menu, ${event.visibleCount} visible`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- An event listener added to a plain nav link is not copied to the menu. Add the listener to the nav or to the wrapper and check `event.target`, so it works for both.
|
||||||
|
- A plain link in the menu keeps its `href`, but not its `id`.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- The More toggle is a `<button>` with `aria-expanded`, and the menu works with the keyboard like any other dropdown.
|
||||||
|
- An icon-only toggle is named `More` with `aria-label`. For a page in another language, set `moreText` or write your own toggle with a translated `aria-label`.
|
||||||
|
- A moved dropdown gets `role="button"` and `tabindex="0"` in the menu, so it can be reached with the keyboard.
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
---
|
||||||
|
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
|
||||||
|
import Card from '@ui/Card.astro'
|
||||||
|
import CardBody from '@ui/CardBody.astro'
|
||||||
|
import CardTitle from '@ui/CardTitle.astro'
|
||||||
|
import CardSubtitle from '@ui/CardSubtitle.astro'
|
||||||
|
import DocsLink from '@ui/DocsLink.astro'
|
||||||
|
import Icon from '@ui/Icon.astro'
|
||||||
|
|
||||||
|
const links = ['Home', 'Dashboard', 'Products', 'Services', 'Analytics', 'Reports', 'Settings', 'Help']
|
||||||
|
|
||||||
|
const navbarLinks = [
|
||||||
|
{ title: 'Home', icon: 'home' },
|
||||||
|
{ title: 'Analytics', icon: 'chart-bar' },
|
||||||
|
{ title: 'Products', icon: 'package' },
|
||||||
|
{ title: 'Customers', icon: 'users' },
|
||||||
|
{ title: 'Reports', icon: 'file-text' },
|
||||||
|
{ title: 'Settings', icon: 'settings' },
|
||||||
|
]
|
||||||
|
---
|
||||||
|
|
||||||
|
<DefaultLayout title="Nav overflow" pageMenu="base.nav-overflow" description="Nav overflow moves nav items that do not fit into a More dropdown.">
|
||||||
|
<DocsLink slot="page-header-actions" path="/ui/components/nav-overflow" />
|
||||||
|
|
||||||
|
<div class="row row-cards">
|
||||||
|
<div class="col-12">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>Pills</CardTitle>
|
||||||
|
<CardSubtitle>Wrap a <code>.nav</code> in <code>.nav-overflow</code> with <code>data-bs-toggle="nav-overflow"</code>.</CardSubtitle>
|
||||||
|
<div class="nav-overflow" data-bs-toggle="nav-overflow">
|
||||||
|
<ul class="nav nav-pills">
|
||||||
|
{
|
||||||
|
links.map((link, index) => (
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class:list={['nav-link', index === 0 && 'active']} aria-current={index === 0 ? 'page' : undefined} href="#">
|
||||||
|
{link}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>Tabs</CardTitle>
|
||||||
|
<CardSubtitle>Works with <code>.nav-tabs</code> and keeps <code>.active</code> and <code>.disabled</code> in the menu.</CardSubtitle>
|
||||||
|
<div class="nav-overflow" data-bs-toggle="nav-overflow">
|
||||||
|
<ul class="nav nav-tabs">
|
||||||
|
<li class="nav-item"><a class="nav-link active" aria-current="page" href="#">Overview</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Details</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">History</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Activity</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link disabled" aria-disabled="true">Comments</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Attachments</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>Keep items visible</CardTitle>
|
||||||
|
<CardSubtitle>Add <code>.nav-overflow-keep</code> to items that must never move to the menu.</CardSubtitle>
|
||||||
|
<div class="nav-overflow" data-bs-toggle="nav-overflow">
|
||||||
|
<ul class="nav nav-underline">
|
||||||
|
<li class="nav-item nav-overflow-keep"><a class="nav-link active" aria-current="page" href="#">Home</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Products</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Services</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">About</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Blog</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Careers</a></li>
|
||||||
|
<li class="nav-item nav-overflow-keep"><a class="nav-link" href="#">Contact</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>With dropdowns</CardTitle>
|
||||||
|
<CardSubtitle>A nav item with a dropdown becomes a nested dropdown in the menu.</CardSubtitle>
|
||||||
|
<div class="nav-overflow" data-bs-toggle="nav-overflow">
|
||||||
|
<ul class="nav nav-pills">
|
||||||
|
<li class="nav-item"><a class="nav-link active" aria-current="page" href="#">Home</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Dashboard</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Services</a></li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" role="button" aria-expanded="false">Products</a>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<a class="dropdown-item" href="#">Laptops</a>
|
||||||
|
<a class="dropdown-item" href="#">Phones</a>
|
||||||
|
<a class="dropdown-item" href="#">Tablets</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" role="button" aria-expanded="false">Company</a>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<a class="dropdown-item" href="#">About</a>
|
||||||
|
<a class="dropdown-item" href="#">Careers</a>
|
||||||
|
<a class="dropdown-item" href="#">Press</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="#">Help</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>Icon-only toggle</CardTitle>
|
||||||
|
<CardSubtitle>Set <code>data-bs-more-text="false"</code> to show only the icon.</CardSubtitle>
|
||||||
|
<div class="nav-overflow" data-bs-toggle="nav-overflow" data-bs-more-text="false">
|
||||||
|
<ul class="nav nav-pills">
|
||||||
|
{
|
||||||
|
links.map((link, index) => (
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class:list={['nav-link', index === 0 && 'active']} aria-current={index === 0 ? 'page' : undefined} href="#">
|
||||||
|
{link}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>In a navbar</CardTitle>
|
||||||
|
<CardSubtitle>Wrap the <code>.navbar-nav</code> of a navbar in <code>.nav-overflow</code>.</CardSubtitle>
|
||||||
|
<div class="navbar navbar-expand rounded border">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="#">Brand</a>
|
||||||
|
<nav class="nav-overflow" data-bs-toggle="nav-overflow" aria-label="Demo">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{
|
||||||
|
navbarLinks.map((link, index) => (
|
||||||
|
<li class:list={['nav-item', index === 0 && 'active']}>
|
||||||
|
<a class="nav-link" href="#" aria-current={index === 0 ? 'page' : undefined}>
|
||||||
|
<span class="nav-link-icon">
|
||||||
|
<Icon name={link.icon} />
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">{link.title}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" href="#" data-bs-toggle="dropdown" data-bs-auto-close="outside" role="button" aria-expanded="false">
|
||||||
|
<span class="nav-link-icon">
|
||||||
|
<Icon name="help" />
|
||||||
|
</span>
|
||||||
|
<span class="nav-link-title">Help</span>
|
||||||
|
</a>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<a class="dropdown-item" href="#">Documentation</a>
|
||||||
|
<a class="dropdown-item" href="#">Changelog</a>
|
||||||
|
<a class="dropdown-item" href="#">Support</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<CardTitle>Colored navbar</CardTitle>
|
||||||
|
<CardSubtitle>The overflow menu follows the theme of the navbar, here <code>data-bs-theme="dark"</code>.</CardSubtitle>
|
||||||
|
<div class="navbar navbar-expand rounded bg-primary" data-bs-theme="dark">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="#">Brand</a>
|
||||||
|
<nav class="nav-overflow" data-bs-toggle="nav-overflow" aria-label="Colored demo">
|
||||||
|
<ul class="navbar-nav">
|
||||||
|
{
|
||||||
|
navbarLinks.map((link, index) => (
|
||||||
|
<li class:list={['nav-item', index === 0 && 'active']}>
|
||||||
|
<a class="nav-link" href="#" aria-current={index === 0 ? 'page' : undefined}>
|
||||||
|
<span class="nav-link-title">{link.title}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DefaultLayout>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
---
|
||||||
|
// Nav overflow in the header of a small app screen. The header is too narrow
|
||||||
|
// for all twelve links, so the last ones sit in the More menu, shown open with the
|
||||||
|
// cursor over one of the moved links. The nav and its menu stay at full opacity
|
||||||
|
// and everything else fades out with the distance from them, so the eye lands
|
||||||
|
// on the nav and the rest only sets the scene.
|
||||||
|
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
|
||||||
|
import Card from '@ui/Card.astro'
|
||||||
|
import CardBody from '@ui/CardBody.astro'
|
||||||
|
|
||||||
|
const links = ['Overview', 'Dashboard', 'Products', 'Services', 'Analytics', 'Reports', 'Customers', 'Invoices', 'Team', 'Billing', 'Settings', 'Help']
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: 'Revenue', value: '$48,290', change: '+8.2%' },
|
||||||
|
{ label: 'Orders', value: '1,284', change: '+3.1%' },
|
||||||
|
{ label: 'Customers', value: '9,712', change: '+5.4%' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const orders = [
|
||||||
|
{ id: '#4821', customer: 'Northwind Traders', status: 'Paid', total: '$1,240.00' },
|
||||||
|
{ id: '#4820', customer: 'Blue Harbor Studio', status: 'Paid', total: '$389.50' },
|
||||||
|
{ id: '#4819', customer: 'Juniper & Co.', status: 'Pending', total: '$2,015.00' },
|
||||||
|
]
|
||||||
|
---
|
||||||
|
|
||||||
|
<ScreenshotLayout title="Nav overflow" columns={3}>
|
||||||
|
<div class="shot-nav-overflow">
|
||||||
|
<Card>
|
||||||
|
<div class="card-header gap-3">
|
||||||
|
<img class="shot-nav-overflow-logo" src="/static/logo-small.svg" width="32" height="32" alt="Tabler" />
|
||||||
|
<div class="nav-overflow" data-bs-toggle="nav-overflow" id="shot-nav-overflow">
|
||||||
|
<ul class="nav nav-pills">
|
||||||
|
{
|
||||||
|
links.map((link, index) => (
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class:list={['nav-link', index === 0 && 'active']} aria-current={index === 0 ? 'page' : undefined} href="#">
|
||||||
|
{link}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<CardBody>
|
||||||
|
<div class="row row-cards">
|
||||||
|
{
|
||||||
|
stats.map((stat) => (
|
||||||
|
<div class="col-4">
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<div class="subheader">{stat.label}</div>
|
||||||
|
<div class="d-flex align-items-baseline">
|
||||||
|
<div class="h1 mb-0 me-2">{stat.value}</div>
|
||||||
|
<span class="text-green">{stat.change}</span>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
<div class="col-12">
|
||||||
|
<Card>
|
||||||
|
<table class="table table-vcenter card-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Order</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{
|
||||||
|
orders.map((order) => (
|
||||||
|
<tr>
|
||||||
|
<td class="text-secondary">{order.id}</td>
|
||||||
|
<td>{order.customer}</td>
|
||||||
|
<td class="text-secondary">{order.status}</td>
|
||||||
|
<td class="text-end">{order.total}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style is:inline>
|
||||||
|
.shot-nav-overflow {
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-size: 100% 100%;
|
||||||
|
mask-size: 100% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The mark is cut out of the logo, so it takes the colour behind it. Keep it
|
||||||
|
white on a dark header too; the patch is smaller than the logo, so it never
|
||||||
|
shows around the rounded corners. */
|
||||||
|
.shot-nav-overflow-logo {
|
||||||
|
background: linear-gradient(#fff, #fff) center / 75% 75% no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shot-nav-overflow-hover {
|
||||||
|
position: relative;
|
||||||
|
color: var(--tblr-dropdown-link-hover-color);
|
||||||
|
background-color: var(--tblr-dropdown-link-hover-bg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<!-- The open menu is the subject of this capture, so it is opened here
|
||||||
|
rather than left to a click the capture tool cannot make. The component
|
||||||
|
builds the menu items, so the hovered one gets its look and the cursor here.
|
||||||
|
The mask is drawn from the measured nav and menu: two blurred boxes, grown
|
||||||
|
enough to be solid over the subject and to fade out around it. -->
|
||||||
|
<script is:inline>
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const wrapper = document.getElementById('shot-nav-overflow')
|
||||||
|
const { Dropdown } = window.tabler ?? {}
|
||||||
|
if (!wrapper || !Dropdown) return
|
||||||
|
|
||||||
|
Dropdown.getOrCreateInstance(wrapper.querySelector('.nav-overflow-toggle')).show()
|
||||||
|
|
||||||
|
// Opening a menu moves the focus to it; the picture shows the menu, not a focus ring
|
||||||
|
document.activeElement?.blur()
|
||||||
|
|
||||||
|
const hovered = wrapper.querySelectorAll('.nav-overflow-menu > .dropdown-item')[1]
|
||||||
|
if (!hovered) return
|
||||||
|
|
||||||
|
const cursor = document.createElement('span')
|
||||||
|
cursor.className = 'cursor'
|
||||||
|
cursor.style.cssText = 'top: 60%; left: 55%'
|
||||||
|
hovered.classList.add('shot-nav-overflow-hover')
|
||||||
|
hovered.append(cursor)
|
||||||
|
|
||||||
|
const scene = document.querySelector('.shot-nav-overflow')
|
||||||
|
const menu = wrapper.querySelector('.nav-overflow-menu')
|
||||||
|
const frame = scene.getBoundingClientRect()
|
||||||
|
const blur = 56
|
||||||
|
const grow = blur * 2
|
||||||
|
|
||||||
|
const boxes = [wrapper, menu].map((element) => {
|
||||||
|
const box = element.getBoundingClientRect()
|
||||||
|
const x = box.left - frame.left - grow
|
||||||
|
const y = box.top - frame.top - grow
|
||||||
|
|
||||||
|
return `<rect x="${x}" y="${y}" width="${box.width + 2 * grow}" height="${box.height + 2 * grow}" rx="${grow}"/>`
|
||||||
|
})
|
||||||
|
|
||||||
|
const svg =
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${frame.width} ${frame.height}" preserveAspectRatio="none">` +
|
||||||
|
`<filter id="f" filterUnits="userSpaceOnUse" x="-500" y="-500" width="${frame.width + 1000}" height="${frame.height + 1000}"><feGaussianBlur stdDeviation="${blur}"/></filter>` +
|
||||||
|
`<g filter="url(#f)">${boxes.join('')}</g></svg>`
|
||||||
|
const image = `url("data:image/svg+xml,${encodeURIComponent(svg)}")`
|
||||||
|
|
||||||
|
scene.style.webkitMaskImage = image
|
||||||
|
scene.style.maskImage = image
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
</ScreenshotLayout>
|
||||||
@@ -105,7 +105,7 @@ const headerClass = [
|
|||||||
{hideSearch ? (
|
{hideSearch ? (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Fragment set:html={'<!-- BEGIN NAVBAR MENU -->'} />
|
<Fragment set:html={'<!-- BEGIN NAVBAR MENU -->'} />
|
||||||
<nav aria-label="Primary">
|
<nav aria-label="Primary" class="nav-overflow" data-bs-toggle="nav-overflow">
|
||||||
<NavbarMenu sample={sample} hideIcons={hideIcons} pageMenu={pageMenu} />
|
<NavbarMenu sample={sample} hideIcons={hideIcons} pageMenu={pageMenu} />
|
||||||
</nav>
|
</nav>
|
||||||
<Fragment set:html={'<!-- END NAVBAR MENU -->'} />
|
<Fragment set:html={'<!-- END NAVBAR MENU -->'} />
|
||||||
@@ -113,7 +113,7 @@ const headerClass = [
|
|||||||
) : (
|
) : (
|
||||||
<div class="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
|
<div class="d-flex flex-column flex-md-row flex-fill align-items-stretch align-items-md-center">
|
||||||
<!-- BEGIN NAVBAR MENU -->
|
<!-- BEGIN NAVBAR MENU -->
|
||||||
<nav aria-label="Primary">
|
<nav aria-label="Primary" class="nav-overflow" data-bs-toggle="nav-overflow">
|
||||||
<NavbarMenu sample={sample} hideIcons={hideIcons} pageMenu={pageMenu} />
|
<NavbarMenu sample={sample} hideIcons={hideIcons} pageMenu={pageMenu} />
|
||||||
</nav>
|
</nav>
|
||||||
<!-- END NAVBAR MENU -->
|
<!-- END NAVBAR MENU -->
|
||||||
@@ -135,7 +135,7 @@ const headerClass = [
|
|||||||
<div class="row flex-column flex-md-row flex-fill align-items-center">
|
<div class="row flex-column flex-md-row flex-fill align-items-center">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<!-- BEGIN NAVBAR MENU -->
|
<!-- BEGIN NAVBAR MENU -->
|
||||||
<nav aria-label="Primary">
|
<nav aria-label="Primary" class="nav-overflow" data-bs-toggle="nav-overflow">
|
||||||
<NavbarMenu longTitles sample={sample} hideIcons={hideIcons} pageMenu={pageMenu} />
|
<NavbarMenu longTitles sample={sample} hideIcons={hideIcons} pageMenu={pageMenu} />
|
||||||
</nav>
|
</nav>
|
||||||
<!-- END NAVBAR MENU -->
|
<!-- END NAVBAR MENU -->
|
||||||
|
|||||||
@@ -254,6 +254,10 @@
|
|||||||
"title": "Modal",
|
"title": "Modal",
|
||||||
"url": "/ui/components/modal"
|
"url": "/ui/components/modal"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "Nav overflow",
|
||||||
|
"url": "/ui/components/nav-overflow"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"title": "Offcanvas",
|
"title": "Offcanvas",
|
||||||
"url": "/ui/components/offcanvas"
|
"url": "/ui/components/offcanvas"
|
||||||
|
|||||||
@@ -198,6 +198,11 @@
|
|||||||
"url": "navigation.html",
|
"url": "navigation.html",
|
||||||
"title": "Navigation"
|
"title": "Navigation"
|
||||||
},
|
},
|
||||||
|
"nav-overflow": {
|
||||||
|
"url": "nav-overflow.html",
|
||||||
|
"title": "Nav overflow",
|
||||||
|
"badge": "New"
|
||||||
|
},
|
||||||
"offcanvas": {
|
"offcanvas": {
|
||||||
"title": "Offcanvas",
|
"title": "Offcanvas",
|
||||||
"url": "offcanvas.html"
|
"url": "offcanvas.html"
|
||||||
|
|||||||
Reference in new issue
Block a user