diff --git a/.build/markup-classes-baseline.txt b/.build/markup-classes-baseline.txt index 92b96aac2..a55da722b 100644 --- a/.build/markup-classes-baseline.txt +++ b/.build/markup-classes-baseline.txt @@ -22,6 +22,7 @@ js-select-illustration js-select-skin-color line list +nav-overflow-keep settings slide sort-category diff --git a/.changeset/nav-overflow.md b/.changeset/nav-overflow.md new file mode 100644 index 000000000..61d1e5cb4 --- /dev/null +++ b/.changeset/nav-overflow.md @@ -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. diff --git a/.changeset/navbar-nav-overflow.md b/.changeset/navbar-nav-overflow.md new file mode 100644 index 000000000..e063a7112 --- /dev/null +++ b/.changeset/navbar-nav-overflow.md @@ -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`. diff --git a/core/js/src/bootstrap.ts b/core/js/src/bootstrap.ts index 26f9973c9..01aa224cf 100644 --- a/core/js/src/bootstrap.ts +++ b/core/js/src/bootstrap.ts @@ -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, diff --git a/core/js/src/bootstrap/nav-overflow.ts b/core/js/src/bootstrap/nav-overflow.ts new file mode 100644 index 000000000..653403625 --- /dev/null +++ b/core/js/src/bootstrap/nav-overflow.ts @@ -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 & Record + +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: + '', + threshold: 0, +} + +const DefaultType: Record = { + 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 + _resizeObserver: ResizeObserver | null + _resizeHandler: (() => void) | null + _collapseBelow: number + _itemMenus: Map + _relocatedMenus: Map + + 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 { + 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(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(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 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 diff --git a/core/js/src/bootstrap/util/sanitizer.ts b/core/js/src/bootstrap/util/sanitizer.ts index 0bfd4559c..cc3ff0056 100644 --- a/core/js/src/bootstrap/util/sanitizer.ts +++ b/core/js/src/bootstrap/util/sanitizer.ts @@ -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 diff --git a/core/js/tests/unit/nav-overflow.spec.ts b/core/js/tests/unit/nav-overflow.spec.ts new file mode 100644 index 000000000..ff2ea2194 --- /dev/null +++ b/core/js/tests/unit/nav-overflow.spec.ts @@ -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 = `` + +const links = (count: number, extra: Record = {}): string => Array.from({ length: count }, (_, index) => extra[index] ?? ``).join('') + +const nav = (width: number, items: string, attributes = ''): string => `${STYLE}` + +describe('NavOverflow', () => { + let fixtureEl: HTMLElement + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + const wrapper = (): HTMLElement => fixtureEl.querySelector('.nav-overflow')! + const hidden = (): HTMLElement[] => [...fixtureEl.querySelectorAll('.nav-overflow > ul > .nav-item[data-bs-nav-overflow="true"]')] + const menuItems = (): HTMLElement[] => [...fixtureEl.querySelectorAll('.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 = '' + + 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('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}
` + + const bare = fixtureEl.querySelector('#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: '' })) + + new NavOverflow(wrapper()) + + expect(menuItems().at(-1)).toHaveClass('active') + }) + + it('should keep nav-overflow-keep items visible', () => { + fixtureEl.innerHTML = nav(360, links(6, { 5: '' })) + + 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: '', + 2: '', + }), + ) + + 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('.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('.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) + '') + + 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: '' }) + + 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: '' }) + + 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('', '') + + new NavOverflow(wrapper(), { moreIcon: '' }) + + 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('.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: '' })) + + new NavOverflow(wrapper(), { collapseBelow: 700 }) + + expect(hidden()).toHaveLength(2) + }) + }) + + describe('dropdowns', () => { + const DROPDOWN_ITEM = '' + + 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('.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('.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: '' }) + + 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('#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('#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) + }) + }) +}) diff --git a/core/package.json b/core/package.json index 7ab155ee3..9e8094cc0 100644 --- a/core/package.json +++ b/core/package.json @@ -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", diff --git a/core/scss/_core.scss b/core/scss/_core.scss index 56df7ac88..30a8202d7 100644 --- a/core/scss/_core.scss +++ b/core/scss/_core.scss @@ -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'; diff --git a/core/scss/ui/_nav-overflow.scss b/core/scss/ui/_nav-overflow.scss new file mode 100644 index 000000000..ea873e3b3 --- /dev/null +++ b/core/scss/ui/_nav-overflow.scss @@ -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}); + } +} diff --git a/docs/content/ui/components/nav-overflow.mdx b/docs/content/ui/components/nav-overflow.mdx new file mode 100644 index 000000000..9894e548d --- /dev/null +++ b/docs/content/ui/components/nav-overflow.mdx @@ -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. + + + + + +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 + + + + + +## 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. + + + + + +## 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. + + + + + +## 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. + + + + + +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 + +``` + +### 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 + +``` + +## 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 + +``` + +## 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: + + + +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 `