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

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

No files matched your search

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