diff --git a/.cspell.json b/.cspell.json index 8fce1c3281e2..0b993929e98a 100644 --- a/.cspell.json +++ b/.cspell.json @@ -73,6 +73,7 @@ "mouseleave", "navbars", "navs", + "navoverflow", "Neue", "noindex", "Noto", diff --git a/js/index.esm.js b/js/index.esm.js index 01d298e05fce..e52911e2accd 100644 --- a/js/index.esm.js +++ b/js/index.esm.js @@ -12,6 +12,7 @@ export { default as Collapse } from './src/collapse.js' export { default as Datepicker } from './src/datepicker.js' export { default as Dialog } from './src/dialog.js' export { default as Dropdown } from './src/dropdown.js' +export { default as NavOverflow } from './src/nav-overflow.js' export { default as Offcanvas } from './src/offcanvas.js' export { default as Strength } from './src/strength.js' export { default as OtpInput } from './src/otp-input.js' diff --git a/js/index.umd.js b/js/index.umd.js index 73f12b424edd..73e7b45c83d6 100644 --- a/js/index.umd.js +++ b/js/index.umd.js @@ -12,6 +12,7 @@ import Collapse from './src/collapse.js' import Datepicker from './src/datepicker.js' import Dialog from './src/dialog.js' import Dropdown from './src/dropdown.js' +import NavOverflow from './src/nav-overflow.js' import Offcanvas from './src/offcanvas.js' import Strength from './src/strength.js' import OtpInput from './src/otp-input.js' @@ -30,6 +31,7 @@ export default { Datepicker, Dialog, Dropdown, + NavOverflow, Offcanvas, Strength, OtpInput, diff --git a/js/src/dropdown.js b/js/src/dropdown.js index 86955dd59738..2053b46dc7a5 100644 --- a/js/src/dropdown.js +++ b/js/src/dropdown.js @@ -97,11 +97,13 @@ const triangleSign = (p1, p2, p3) => const Default = { autoClose: true, boundary: 'clippingParents', + container: false, display: 'dynamic', offset: [0, 2], floatingConfig: null, placement: DEFAULT_PLACEMENT, reference: 'toggle', + strategy: 'absolute', // Submenu options submenuTrigger: 'both', // 'click', 'hover', or 'both' submenuDelay: SUBMENU_CLOSE_DELAY @@ -110,11 +112,13 @@ const Default = { const DefaultType = { autoClose: '(boolean|string)', boundary: '(string|element)', + container: '(string|element|boolean)', display: 'string', offset: '(array|string|function)', floatingConfig: '(null|object|function)', placement: 'string', reference: '(string|element|object)', + strategy: 'string', submenuTrigger: 'string', submenuDelay: 'number' } @@ -145,6 +149,9 @@ class Dropdown extends BaseComponent { SelectorEngine.prev(this._element, SELECTOR_MENU)[0] || SelectorEngine.findOne(SELECTOR_MENU, this._parent) + // Store original menu parent for container option + this._menuOriginalParent = this._menu?.parentNode + // Parse responsive placements on init this._parseResponsivePlacements() @@ -185,6 +192,9 @@ class Dropdown extends BaseComponent { return } + // Move menu to container if specified (to escape overflow clipping) + this._moveMenuToContainer() + this._createFloating() // If this is a touch-enabled device we add extra @@ -220,6 +230,7 @@ class Dropdown extends BaseComponent { dispose() { this._disposeFloating() + this._restoreMenuToOriginalParent() this._disposeMediaQueryListeners() this._closeAllSubmenus() this._clearAllSubmenuTimeouts() @@ -252,6 +263,9 @@ class Dropdown extends BaseComponent { this._disposeFloating() + // Restore menu to original parent if it was moved + this._restoreMenuToOriginalParent() + this._menu.classList.remove(CLASS_NAME_SHOW) this._element.classList.remove(CLASS_NAME_SHOW) this._parent.classList.remove(CLASS_NAME_SHOW) @@ -326,7 +340,8 @@ class Dropdown extends BaseComponent { referenceElement, this._menu, floatingConfig.placement, - floatingConfig.middleware + floatingConfig.middleware, + floatingConfig.strategy ) } @@ -434,7 +449,8 @@ class Dropdown extends BaseComponent { _getFloatingConfig(placement, middleware) { const defaultConfig = { placement, - middleware + middleware, + strategy: this._config.strategy } return { @@ -450,8 +466,40 @@ class Dropdown extends BaseComponent { } } + _getContainer() { + const { container } = this._config + if (container === false) { + return null + } + + return container === true ? document.body : getElement(container) + } + + _moveMenuToContainer() { + const container = this._getContainer() + if (!container || !this._menu) { + return + } + + // Only move if not already in the container + if (this._menu.parentNode !== container) { + container.append(this._menu) + } + } + + _restoreMenuToOriginalParent() { + if (!this._menuOriginalParent || !this._menu) { + return + } + + // Only restore if menu was moved + if (this._menu.parentNode !== this._menuOriginalParent) { + this._menuOriginalParent.append(this._menu) + } + } + // Shared helper for positioning any floating element - async _applyFloatingPosition(reference, floating, placement, middleware) { + async _applyFloatingPosition(reference, floating, placement, middleware, strategy = 'absolute') { if (!floating.isConnected) { return null } @@ -459,7 +507,7 @@ class Dropdown extends BaseComponent { const { x, y, placement: finalPlacement } = await computePosition( reference, floating, - { placement, middleware } + { placement, middleware, strategy } ) if (!floating.isConnected) { @@ -467,7 +515,7 @@ class Dropdown extends BaseComponent { } Object.assign(floating.style, { - position: 'absolute', + position: strategy, left: `${x}px`, top: `${y}px`, margin: '0' diff --git a/js/src/nav-overflow.js b/js/src/nav-overflow.js new file mode 100644 index 000000000000..7e0ef54c7025 --- /dev/null +++ b/js/src/nav-overflow.js @@ -0,0 +1,284 @@ +/** + * -------------------------------------------------------------------------- + * Bootstrap nav-overflow.js + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + * -------------------------------------------------------------------------- + */ + +import BaseComponent from './base-component.js' +import EventHandler from './dom/event-handler.js' +import SelectorEngine from './dom/selector-engine.js' + +/** + * 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 CLASS_NAME_OVERFLOW = 'nav-overflow' +const CLASS_NAME_OVERFLOW_MENU = 'nav-overflow-menu' +const CLASS_NAME_HIDDEN = 'd-none' + +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 CLASS_NAME_KEEP = 'nav-overflow-keep' + +const Default = { + moreText: 'More', + moreIcon: '', + threshold: 0 // Minimum items to keep visible before showing overflow +} + +const DefaultType = { + moreText: 'string', + moreIcon: 'string', + threshold: 'number' +} + +/** + * Class definition + */ + +class NavOverflow extends BaseComponent { + constructor(element, config) { + super(element, config) + + this._items = [] + this._overflowItems = [] + this._overflowMenu = null + this._overflowToggle = null + this._resizeObserver = null + this._isInitialized = false + + this._init() + } + + // Getters + static get Default() { + return Default + } + + static get DefaultType() { + return DefaultType + } + + static get NAME() { + return NAME + } + + // Public + update() { + this._calculateOverflow() + EventHandler.trigger(this._element, EVENT_UPDATE) + } + + dispose() { + if (this._resizeObserver) { + this._resizeObserver.disconnect() + } + + // Move items back to original positions + this._restoreItems() + + // Remove overflow menu + if (this._overflowToggle && this._overflowToggle.parentElement) { + this._overflowToggle.parentElement.remove() + } + + super.dispose() + } + + // Private + _init() { + // Add overflow class to nav + this._element.classList.add(CLASS_NAME_OVERFLOW) + + // Get all nav items + this._items = [...SelectorEngine.find(SELECTOR_NAV_ITEM, this._element)] + + // Store original order data + for (const [index, item] of this._items.entries()) { + item.dataset.bsNavOrder = index + } + + // Create overflow dropdown if it doesn't exist + this._createOverflowMenu() + + // Setup resize observer + this._setupResizeObserver() + + // Initial calculation + this._calculateOverflow() + + this._isInitialized = true + } + + _createOverflowMenu() { + // Check if overflow menu already exists + this._overflowToggle = SelectorEngine.findOne(SELECTOR_OVERFLOW_TOGGLE, this._element) + + if (this._overflowToggle) { + this._overflowMenu = SelectorEngine.findOne(SELECTOR_OVERFLOW_MENU, this._element) + return + } + + // Create the overflow dropdown item + const overflowItem = document.createElement('li') + overflowItem.className = 'nav-item nav-overflow-item dropdown' + overflowItem.innerHTML = ` + + + ` + + this._element.append(overflowItem) + this._overflowToggle = overflowItem.querySelector(SELECTOR_OVERFLOW_TOGGLE) + this._overflowMenu = overflowItem.querySelector(SELECTOR_OVERFLOW_MENU) + } + + _setupResizeObserver() { + if (typeof ResizeObserver === 'undefined') { + // Fallback for older browsers + EventHandler.on(window, 'resize', () => this._calculateOverflow()) + return + } + + this._resizeObserver = new ResizeObserver(() => { + this._calculateOverflow() + }) + + this._resizeObserver.observe(this._element) + } + + _calculateOverflow() { + // First, restore all items to measure properly + this._restoreItems() + + const navWidth = this._element.offsetWidth + const overflowItem = this._overflowToggle?.closest('.nav-item') + const overflowWidth = overflowItem?.offsetWidth || 0 + + let usedWidth = 0 + const itemsToOverflow = [] + const overflowThreshold = navWidth - overflowWidth - 10 // 10px buffer + + // Calculate which items need to overflow (skip items with keep class) + for (const item of this._items) { + const itemWidth = item.offsetWidth + usedWidth += itemWidth + + // Never overflow items with the keep class + if (item.classList.contains(CLASS_NAME_KEEP)) { + continue + } + + if (usedWidth > overflowThreshold) { + itemsToOverflow.push(item) + } + } + + // Check if we need threshold minimum visible + const visibleCount = this._items.length - itemsToOverflow.length + if (visibleCount < this._config.threshold && this._items.length > this._config.threshold) { + // Add more items to overflow until we reach threshold (but not keep items) + const toMove = this._items.slice(this._config.threshold).filter(item => !item.classList.contains(CLASS_NAME_KEEP)) + itemsToOverflow.length = 0 + itemsToOverflow.push(...toMove) + } + + // Move items to overflow menu + this._moveToOverflow(itemsToOverflow) + + // Show/hide overflow toggle + if (overflowItem) { + if (itemsToOverflow.length > 0) { + overflowItem.classList.remove(CLASS_NAME_HIDDEN) + } else { + overflowItem.classList.add(CLASS_NAME_HIDDEN) + } + } + + // Trigger overflow event if items changed + if (itemsToOverflow.length > 0) { + EventHandler.trigger(this._element, EVENT_OVERFLOW, { + overflowCount: itemsToOverflow.length, + visibleCount: this._items.length - itemsToOverflow.length + }) + } + } + + _moveToOverflow(items) { + if (!this._overflowMenu) { + return + } + + // Clear existing overflow items + this._overflowMenu.innerHTML = '' + this._overflowItems = [] + + for (const item of items) { + // Clone the nav link as a dropdown item + const link = SelectorEngine.findOne(SELECTOR_NAV_LINK, item) + if (!link) { + continue + } + + const dropdownItem = document.createElement('li') + const clonedLink = link.cloneNode(true) + clonedLink.className = 'dropdown-item' + + // Preserve active state + if (link.classList.contains('active')) { + clonedLink.classList.add('active') + } + + // Preserve disabled state + if (link.classList.contains('disabled') || link.hasAttribute('disabled')) { + clonedLink.classList.add('disabled') + } + + dropdownItem.append(clonedLink) + this._overflowMenu.append(dropdownItem) + + // Hide original item + item.classList.add(CLASS_NAME_HIDDEN) + item.dataset.bsNavOverflow = 'true' + + this._overflowItems.push(item) + } + } + + _restoreItems() { + for (const item of this._items) { + item.classList.remove(CLASS_NAME_HIDDEN) + delete item.dataset.bsNavOverflow + } + + if (this._overflowMenu) { + this._overflowMenu.innerHTML = '' + } + + this._overflowItems = [] + } +} + +/** + * Data API implementation + */ + +EventHandler.on(document, 'DOMContentLoaded', () => { + for (const element of SelectorEngine.find('[data-bs-toggle="nav-overflow"]')) { + NavOverflow.getOrCreateInstance(element) + } +}) + +export default NavOverflow diff --git a/js/tests/unit/dropdown.spec.js b/js/tests/unit/dropdown.spec.js index 00462e1068a3..9b79de40dc43 100644 --- a/js/tests/unit/dropdown.spec.js +++ b/js/tests/unit/dropdown.spec.js @@ -788,6 +788,142 @@ describe('Dropdown', () => { }, 10) }) }) + + it('should move menu to body when container is set to body', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + const dropdown = new Dropdown(btnDropdown, { + container: 'body' + }) + + btnDropdown.addEventListener('shown.bs.dropdown', () => { + expect(dropdownMenu.parentNode).toEqual(document.body) + resolve() + }) + + dropdown.show() + }) + }) + + it('should move menu to body when container is set to true', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + const dropdown = new Dropdown(btnDropdown, { + container: true + }) + + btnDropdown.addEventListener('shown.bs.dropdown', () => { + expect(dropdownMenu.parentNode).toEqual(document.body) + resolve() + }) + + dropdown.show() + }) + }) + + it('should move menu to specified element when container is an element', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '
', + '' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + const customContainer = fixtureEl.querySelector('#custom-container') + const dropdown = new Dropdown(btnDropdown, { + container: customContainer + }) + + btnDropdown.addEventListener('shown.bs.dropdown', () => { + expect(dropdownMenu.parentNode).toEqual(customContainer) + resolve() + }) + + dropdown.show() + }) + }) + + it('should restore menu to original parent when hidden', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + const originalParent = dropdownMenu.parentNode + const dropdown = new Dropdown(btnDropdown, { + container: 'body' + }) + + btnDropdown.addEventListener('shown.bs.dropdown', () => { + expect(dropdownMenu.parentNode).toEqual(document.body) + dropdown.hide() + }) + + btnDropdown.addEventListener('hidden.bs.dropdown', () => { + expect(dropdownMenu.parentNode).toEqual(originalParent) + resolve() + }) + + dropdown.show() + }) + }) + + it('should work with container via data attribute', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]') + const dropdownMenu = fixtureEl.querySelector('.dropdown-menu') + const dropdown = new Dropdown(btnDropdown) + + btnDropdown.addEventListener('shown.bs.dropdown', () => { + expect(dropdownMenu.parentNode).toEqual(document.body) + resolve() + }) + + dropdown.show() + }) + }) }) describe('hide', () => { diff --git a/js/tests/unit/nav-overflow.spec.js b/js/tests/unit/nav-overflow.spec.js new file mode 100644 index 000000000000..89cad83d50af --- /dev/null +++ b/js/tests/unit/nav-overflow.spec.js @@ -0,0 +1,271 @@ +import NavOverflow from '../../src/nav-overflow.js' +import { clearFixture, getFixture } from '../helpers/fixture.js' + +describe('NavOverflow', () => { + let fixtureEl + + beforeAll(() => { + fixtureEl = getFixture() + }) + + afterEach(() => { + clearFixture() + }) + + describe('VERSION', () => { + it('should return plugin version', () => { + expect(NavOverflow.VERSION).toEqual(jasmine.any(String)) + }) + }) + + describe('Default', () => { + it('should return plugin default config', () => { + expect(NavOverflow.Default).toEqual(jasmine.any(Object)) + expect(NavOverflow.Default.moreText).toEqual('More') + expect(NavOverflow.Default.threshold).toEqual(0) + }) + }) + + describe('DATA_KEY', () => { + it('should return plugin data key', () => { + expect(NavOverflow.DATA_KEY).toEqual('bs.navoverflow') + }) + }) + + describe('constructor', () => { + it('should take care of element either passed as a CSS selector or DOM element', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]') + const navBySelector = new NavOverflow('[data-bs-toggle="nav-overflow"]') + const navByElement = new NavOverflow(navEl) + + expect(navBySelector._element).toEqual(navEl) + expect(navByElement._element).toEqual(navEl) + + navBySelector.dispose() + navByElement.dispose() + }) + + it('should add nav-overflow class to element', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]') + const navOverflow = new NavOverflow(navEl) + + expect(navEl).toHaveClass('nav-overflow') + + navOverflow.dispose() + }) + + it('should create overflow menu toggle and dropdown', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]') + const navOverflow = new NavOverflow(navEl) + + const toggle = navEl.querySelector('.nav-overflow-toggle') + const menu = navEl.querySelector('.nav-overflow-menu') + + expect(toggle).not.toBeNull() + expect(menu).not.toBeNull() + expect(toggle).toHaveClass('dropdown-toggle') + expect(menu).toHaveClass('dropdown-menu') + + navOverflow.dispose() + }) + + it('should store order data on nav items', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]') + const navOverflow = new NavOverflow(navEl) + const items = navEl.querySelectorAll('.nav-item:not(.nav-overflow-item)') + + expect(items[0].dataset.bsNavOrder).toEqual('0') + expect(items[1].dataset.bsNavOrder).toEqual('1') + expect(items[2].dataset.bsNavOrder).toEqual('2') + + navOverflow.dispose() + }) + + it('should respect custom moreText option', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]') + const navOverflow = new NavOverflow(navEl, { + moreText: 'See all' + }) + + const toggleText = navEl.querySelector('.nav-overflow-text') + expect(toggleText.textContent).toEqual('See all') + + navOverflow.dispose() + }) + }) + + describe('update', () => { + it('should trigger update event', () => { + return new Promise(resolve => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]') + const navOverflow = new NavOverflow(navEl) + + navEl.addEventListener('update.bs.navoverflow', () => { + navOverflow.dispose() + resolve() + }) + + navOverflow.update() + }) + }) + }) + + describe('dispose', () => { + it('should dispose nav overflow and remove overflow menu', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]') + const navOverflow = new NavOverflow(navEl) + + expect(NavOverflow.getInstance(navEl)).not.toBeNull() + expect(navEl.querySelector('.nav-overflow-toggle')).not.toBeNull() + + navOverflow.dispose() + + expect(NavOverflow.getInstance(navEl)).toBeNull() + expect(navEl.querySelector('.nav-overflow-toggle')).toBeNull() + }) + }) + + describe('getInstance', () => { + it('should return nav overflow instance', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]') + const navOverflow = new NavOverflow(navEl) + + expect(NavOverflow.getInstance(navEl)).toEqual(navOverflow) + expect(NavOverflow.getInstance(navEl)).toBeInstanceOf(NavOverflow) + + navOverflow.dispose() + }) + + it('should return null when there is no instance', () => { + fixtureEl.innerHTML = '' + + const navEl = fixtureEl.querySelector('.nav') + + expect(NavOverflow.getInstance(navEl)).toBeNull() + }) + }) + + describe('getOrCreateInstance', () => { + it('should return nav overflow instance', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]') + const navOverflow = new NavOverflow(navEl) + + expect(NavOverflow.getOrCreateInstance(navEl)).toEqual(navOverflow) + expect(NavOverflow.getInstance(navEl)).toEqual(NavOverflow.getOrCreateInstance(navEl, {})) + expect(NavOverflow.getOrCreateInstance(navEl)).toBeInstanceOf(NavOverflow) + + navOverflow.dispose() + }) + + it('should return new instance when there is no instance', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const navEl = fixtureEl.querySelector('.nav') + + expect(NavOverflow.getInstance(navEl)).toBeNull() + + const instance = NavOverflow.getOrCreateInstance(navEl) + expect(instance).toBeInstanceOf(NavOverflow) + + instance.dispose() + }) + }) + + describe('overflow behavior', () => { + it('should use dropdown with container option for overflow menu', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]') + const navOverflow = new NavOverflow(navEl) + + const toggle = navEl.querySelector('.nav-overflow-toggle') + expect(toggle.getAttribute('data-bs-container')).toEqual('body') + expect(toggle.getAttribute('data-bs-strategy')).toEqual('fixed') + + navOverflow.dispose() + }) + + it('should preserve nav-overflow-keep items', () => { + fixtureEl.innerHTML = [ + '' + ].join('') + + const navEl = fixtureEl.querySelector('[data-bs-toggle="nav-overflow"]') + const navOverflow = new NavOverflow(navEl) + const keepItem = navEl.querySelector('.nav-overflow-keep') + + // The keep item should never be hidden + expect(keepItem).not.toHaveClass('d-none') + + navOverflow.dispose() + }) + }) +}) diff --git a/scss/_nav-overflow.scss b/scss/_nav-overflow.scss new file mode 100644 index 000000000000..fb85ef3ed156 --- /dev/null +++ b/scss/_nav-overflow.scss @@ -0,0 +1,30 @@ +// Nav Overflow (Priority+ Pattern) +// +// A responsive navigation pattern that automatically moves items +// to an overflow dropdown when space is limited. + +@use "config" as *; +@use "variables" as *; + +@layer components { + .nav-overflow { + flex-wrap: nowrap; + min-width: 0; // Allow flex child to shrink below content width + } + + // Container item for overflow + .nav-overflow-item { + flex-shrink: 0; + margin-inline-start: auto; + } + + // Hide items that have been moved to overflow + .nav-overflow [data-bs-nav-overflow="true"] { + display: none; + } + + // Preserve items that should never overflow + .nav-overflow-keep { + flex-shrink: 0; + } +} diff --git a/scss/_nav.scss b/scss/_nav.scss index 773c21dcd436..733c8ce9ad71 100644 --- a/scss/_nav.scss +++ b/scss/_nav.scss @@ -10,8 +10,8 @@ $nav-gap: .125rem !default; $nav-link-gap: .5rem !default; $nav-link-align: center !default; $nav-link-justify: center !default; -$nav-link-padding-y: .5rem !default; -$nav-link-padding-x: 1rem !default; +$nav-link-padding-y: .375rem !default; +$nav-link-padding-x: .75rem !default; $nav-link-color: var(--fg-2) !default; $nav-link-hover-color: var(--fg-1) !default; $nav-link-hover-bg: var(--bg-1) !default; @@ -80,6 +80,7 @@ $nav-underline-link-active-color: var(--fg-color) !default; font-weight: var(--nav-link-font-weight); color: var(--nav-link-color); text-decoration: none; + white-space: nowrap; background: none; border: 0; @include border-radius(var(--border-radius)); diff --git a/scss/_navbar.scss b/scss/_navbar.scss index 80f53720d85a..005abdbb1e4d 100644 --- a/scss/_navbar.scss +++ b/scss/_navbar.scss @@ -11,23 +11,19 @@ @use "mixins/transition" as *; // scss-docs-start navbar-variables -$navbar-padding-y: $spacer * .5 !default; +$navbar-padding-y: $spacer * .25 !default; $navbar-padding-x: null !default; $navbar-nav-link-padding-x: .75rem !default; $navbar-brand-font-size: $font-size-lg !default; -// Compute the navbar-brand padding-y so the navbar-brand will have the same height as navbar-text and nav-link -// mdo-do: fix this -// $nav-link-height: $font-size-base * $line-height-base + $nav-link-padding-y * 2 !default; -// $navbar-brand-height: $navbar-brand-font-size * $line-height-base !default; $navbar-brand-height: 1.5rem !default; -// $navbar-brand-padding-y: ($nav-link-height - $navbar-brand-height) * .5 !default; $navbar-brand-padding-y: $navbar-brand-height * .5 !default; $navbar-brand-margin-end: 1rem !default; -$navbar-toggler-padding-y: .25rem !default; -$navbar-toggler-padding-x: .75rem !default; +$navbar-toggler-width: 2rem !default; +$navbar-toggler-padding-y: .375rem !default; +$navbar-toggler-padding-x: .375rem !default; $navbar-toggler-font-size: $font-size-lg !default; $navbar-toggler-border-radius: var(--border-radius) !default; $navbar-toggler-transition: box-shadow .15s ease-in-out !default; @@ -36,8 +32,6 @@ $navbar-light-color: var(--fg-2) !default; $navbar-light-hover-color: var(--fg-1) !default; $navbar-light-active-color: var(--fg) !default; $navbar-light-disabled-color: var(--fg-3) !default; -$navbar-light-icon-color: color-mix(in oklch, var(--body-color) 75%, transparent) !default; -$navbar-light-toggler-icon-bg: url("data:image/svg+xml,") !default; $navbar-light-toggler-border-color: color-mix(in oklch, var(--fg-body) 15%, transparent) !default; $navbar-light-brand-color: $navbar-light-active-color !default; $navbar-light-brand-hover-color: $navbar-light-active-color !default; @@ -48,14 +42,13 @@ $navbar-dark-color: rgba($white, .55) !default; $navbar-dark-hover-color: rgba($white, .75) !default; $navbar-dark-active-color: $white !default; $navbar-dark-disabled-color: rgba($white, .25) !default; -$navbar-dark-icon-color: $navbar-dark-color !default; -$navbar-dark-toggler-icon-bg: url("data:image/svg+xml,") !default; $navbar-dark-toggler-border-color: rgba($white, .1) !default; $navbar-dark-brand-color: $navbar-dark-active-color !default; $navbar-dark-brand-hover-color: $navbar-dark-active-color !default; // scss-docs-end navbar-dark-variables @layer components { + // Base navbar .navbar { // scss-docs-start navbar-css-vars // stylelint-disable-next-line scss/at-function-named-arguments @@ -71,10 +64,10 @@ $navbar-dark-brand-hover-color: $navbar-dark-active-color !default; --navbar-brand-color: #{$navbar-light-brand-color}; --navbar-brand-hover-color: #{$navbar-light-brand-hover-color}; --navbar-nav-link-padding-x: #{$navbar-nav-link-padding-x}; + --navbar-toggler-width: #{$navbar-toggler-width}; --navbar-toggler-padding-y: #{$navbar-toggler-padding-y}; --navbar-toggler-padding-x: #{$navbar-toggler-padding-x}; --navbar-toggler-font-size: #{$navbar-toggler-font-size}; - --navbar-toggler-icon-bg: #{escape-svg($navbar-light-toggler-icon-bg)}; --navbar-toggler-border-color: #{$navbar-light-toggler-border-color}; --navbar-toggler-border-radius: #{$navbar-toggler-border-radius}; --navbar-toggler-transition: #{$navbar-toggler-transition}; @@ -82,15 +75,14 @@ $navbar-dark-brand-hover-color: $navbar-dark-active-color !default; position: relative; display: flex; - flex-wrap: wrap; // allow us to do the line break for collapsing content + flex-wrap: wrap; align-items: center; - justify-content: space-between; // space out brand from logo + justify-content: space-between; padding: var(--navbar-padding-y) var(--navbar-padding-x); + @include set-container(); @include gradient-bg(); - // Because flex properties aren't inherited, we need to redeclare these first - // few properties so that content nested within behave properly. - // The `flex-wrap` property is inherited to simplify the expanded navbars + // Container properties for nested containers %container-flex-properties { display: flex; flex-wrap: inherit; @@ -133,81 +125,76 @@ $navbar-dark-brand-hover-color: $navbar-dark-active-color !default; // Navbar nav // - // Custom navbar navigation (doesn't require `.nav`, but does make use of `.nav-link`). + // Navigation within navbars. Sets all nav-link CSS variables needed for + // proper styling. Can be used standalone or with `.nav` base class. .navbar-nav { // scss-docs-start navbar-nav-css-vars - // --nav-link-padding-x: 0; - // @mdo-do: fix this, navbar shouldn't need to reuse nav link variables mb? or we need to bring them in… - // --nav-link-padding-y: #{$nav-link-padding-y}; - // @include rfs($nav-link-font-size, --nav-link-font-size); - // --nav-link-font-weight: #{$nav-link-font-weight}; + // Set all nav-link variables for self-contained styling + --nav-gap: .25rem; + --nav-link-gap: .5rem; + --nav-link-padding-x: .5rem; + --nav-link-padding-y: .5rem; --nav-link-color: var(--navbar-color); --nav-link-hover-color: var(--navbar-hover-color); + --nav-link-hover-bg: transparent; + --nav-link-active-color: var(--navbar-active-color); + --nav-link-active-bg: transparent; --nav-link-disabled-color: var(--navbar-disabled-color); // scss-docs-end navbar-nav-css-vars display: flex; - flex-direction: column; // cannot use `inherit` to get the `.navbar`s value + flex-direction: column; + gap: var(--nav-gap); padding-inline-start: 0; margin-bottom: 0; list-style: none; .nav-link { + white-space: nowrap; + &.active, &.show { color: var(--navbar-active-color); } } - - // .dropdown-menu { - // position: static; - // } } // Navbar text // - // + // For adding text or inline elements to the navbar .navbar-text { - // @mdo-do: fix this too - // padding-top: $nav-link-padding-y; - // padding-bottom: $nav-link-padding-y; + padding-top: var(--navbar-brand-padding-y); + padding-bottom: var(--navbar-brand-padding-y); color: var(--navbar-color); a, a:hover, - a:focus { + a:focus { color: var(--navbar-active-color); } } - // Responsive navbar + // Navbar toggler // - // Custom styles for responsive collapsing and toggling of navbar contents. - // Powered by the collapse Bootstrap JavaScript plugin. - - // When collapsed, prevent the toggleable navbar contents from appearing in - // the default flexbox row orientation. Requires the use of `flex-wrap: wrap` - // on the `.navbar` parent. - .navbar-collapse { - flex-grow: 1; - flex-basis: 100%; - // For always expanded or extra full navbars, ensure content aligns itself - // properly vertically. Can be easily overridden with flex utilities. - align-items: center; - } - // Button for toggling the navbar when in its collapsed state + .navbar-toggler { - padding: var(--navbar-toggler-padding-y) var(--navbar-toggler-padding-x); - font-size: var(--navbar-toggler-font-size); - line-height: 1; + display: flex; + align-items: center; + justify-content: center; + width: var(--navbar-toggler-width); + aspect-ratio: 1 / 1; + // padding: var(--navbar-toggler-padding-y) var(--navbar-toggler-padding-x); + // font-size: var(--navbar-toggler-font-size); + // line-height: 1; color: var(--navbar-color); - background-color: transparent; // remove default button style - border: var(--border-width) solid var(--navbar-toggler-border-color); // remove default button style + background-color: transparent; + border: 0; + // border: var(--border-width) solid var(--navbar-toggler-border-color); @include border-radius(var(--navbar-toggler-border-radius)); @include transition(var(--navbar-toggler-transition)); @@ -221,101 +208,98 @@ $navbar-dark-brand-hover-color: $navbar-dark-active-color !default; } } - // Keep as a separate element so folks can easily override it with another icon - // or image file as needed. + // Navbar toggler icon (inline SVG) .navbar-toggler-icon { display: inline-block; - width: 1.5em; - height: 1.5em; - vertical-align: middle; - background-image: var(--navbar-toggler-icon-bg); - background-repeat: no-repeat; - background-position: center; - background-size: 100%; + width: 1rem; + height: 1rem; + color: var(--navbar-color); } - .navbar-nav-scroll { - max-height: var(--scroll-height, 75vh); - overflow-y: auto; - } // scss-docs-start navbar-expand-loop // Generate series of `.navbar-expand-*` responsive classes for configuring - // where your navbar collapses. + // where your navbar collapses and expands. Uses container queries so the + // navbar responds to its own width, not the viewport width. + + // Mixin for expanded state styles (applied to descendants) + @mixin navbar-expanded { + // Style the inner container since we can't style .navbar itself with container queries + > .container, + > .container-fluid, + %navbar-expand-container { + flex-wrap: nowrap; + justify-content: flex-start; + } + + .navbar-nav { + --nav-link-padding-x: var(--navbar-nav-link-padding-x); + flex-direction: row; + } + + .navbar-toggler { + display: none !important; // stylelint-disable-line declaration-no-important + } + + .offcanvas { + // stylelint-disable declaration-no-important + position: static; + z-index: auto; + flex-grow: 1; + width: auto !important; + height: auto !important; + visibility: visible !important; + background-color: transparent !important; + border: 0 !important; + transform: none !important; + @include box-shadow(none); + @include transition(none); + // stylelint-enable declaration-no-important + + .offcanvas-header { + display: none; + } + + .offcanvas-body { + display: flex; + flex-grow: 0; + flex-direction: row; + align-items: center; + padding: 0; + overflow-y: visible; + } + } + } + + // Always expanded (no responsive behavior) .navbar-expand { - @each $breakpoint in map.keys($grid-breakpoints) { - $next: breakpoint-next($breakpoint, $grid-breakpoints); - $infix: breakpoint-infix($next, $grid-breakpoints); - - // stylelint-disable-next-line scss/selector-no-union-class-name - &#{$infix} { - @include media-breakpoint-up($next) { - flex-wrap: nowrap; - justify-content: flex-start; - - .navbar-nav { - --nav-link-padding-x: var(--navbar-nav-link-padding-x); - flex-direction: row; - - // .dropdown-menu { - // position: absolute; - // } - - // .nav-link { - // padding-inline: var(--navbar-nav-link-padding-x); - // } - } - - .navbar-nav-scroll { - overflow: visible; - } - - .navbar-collapse { - display: flex !important; // stylelint-disable-line declaration-no-important - flex-basis: auto; - } - - .navbar-toggler { - display: none !important; // stylelint-disable-line declaration-no-important - } - - .offcanvas { - // stylelint-disable declaration-no-important - position: static; - z-index: auto; - flex-grow: 1; - width: auto !important; - height: auto !important; - visibility: visible !important; - background-color: transparent !important; - border: 0 !important; - transform: none !important; - @include box-shadow(none); - @include transition(none); - // stylelint-enable declaration-no-important - - .offcanvas-header { - display: none; - } - - .offcanvas-body { - display: flex; - flex-grow: 0; - padding: 0; - overflow-y: visible; - } - } + @include navbar-expanded(); + + // Also set on navbar itself for non-responsive case + flex-wrap: nowrap; + justify-content: flex-start; + } + + // Responsive navbar expand classes using container queries + @each $breakpoint in map.keys($grid-breakpoints) { + $next: breakpoint-next($breakpoint, $grid-breakpoints); + $infix: breakpoint-infix($next, $grid-breakpoints); + + @if $next { + .navbar-expand#{$infix} { + @include container-breakpoint-up($next) { + @include navbar-expanded(); } } } } // scss-docs-end navbar-expand-loop + // Navbar themes // - // Styles for switching between navbars with light or dark background. + // Style for dark navbar backgrounds. Use data-bs-theme="dark" for modern approach. - .navbar-dark, .navbar[data-bs-theme="dark"] { // scss-docs-start navbar-dark-css-vars --navbar-color: #{$navbar-dark-color}; @@ -325,15 +309,6 @@ $navbar-dark-brand-hover-color: $navbar-dark-active-color !default; --navbar-brand-color: #{$navbar-dark-brand-color}; --navbar-brand-hover-color: #{$navbar-dark-brand-hover-color}; --navbar-toggler-border-color: #{$navbar-dark-toggler-border-color}; - --navbar-toggler-icon-bg: #{escape-svg($navbar-dark-toggler-icon-bg)}; // scss-docs-end navbar-dark-css-vars } - - @if $enable-dark-mode { - @include color-mode(dark) { - .navbar-toggler-icon { - --navbar-toggler-icon-bg: #{escape-svg($navbar-dark-toggler-icon-bg)}; - } - } - } } diff --git a/scss/_offcanvas.scss b/scss/_offcanvas.scss index 8dfbb55d8ccc..4ce9ada18b83 100644 --- a/scss/_offcanvas.scss +++ b/scss/_offcanvas.scss @@ -6,6 +6,22 @@ @use "mixins/backdrop" as *; @use "layout/breakpoints" as *; +// scss-docs-start offcanvas-variables +$offcanvas-padding-y: $spacer !default; +$offcanvas-padding-x: $spacer !default; +$offcanvas-horizontal-width: 400px !default; +$offcanvas-vertical-height: 30vh !default; +$offcanvas-transition-duration: .3s !default; +$offcanvas-border-color: var(--border-color-translucent) !default; +$offcanvas-border-width: var(--border-width) !default; +$offcanvas-title-line-height: $line-height-base !default; +$offcanvas-bg-color: var(--bg-body) !default; +$offcanvas-color: var(--fg-body) !default; +$offcanvas-box-shadow: var(--box-shadow-lg) !default; +$offcanvas-backdrop-bg: $black !default; +$offcanvas-backdrop-opacity: .5 !default; +// scss-docs-end offcanvas-variables + %offcanvas-css-vars { // scss-docs-start offcanvas-css-vars --offcanvas-zindex: #{$zindex-offcanvas}; @@ -24,6 +40,7 @@ } @layer components { + // Apply CSS vars to all offcanvas responsive variants @each $breakpoint in map.keys($grid-breakpoints) { $next: breakpoint-next($breakpoint, $grid-breakpoints); $infix: breakpoint-infix($next, $grid-breakpoints); @@ -33,6 +50,7 @@ } } + // Responsive offcanvas styles @each $breakpoint in map.keys($grid-breakpoints) { $next: breakpoint-next($breakpoint, $grid-breakpoints); $infix: breakpoint-infix($next, $grid-breakpoints); @@ -53,6 +71,7 @@ @include box-shadow(var(--offcanvas-box-shadow)); @include transition(var(--offcanvas-transition)); + // Placement: Start (left in LTR, right in RTL) &.offcanvas-start { inset-block: 0; inset-inline-start: 0; @@ -65,6 +84,7 @@ } } + // Placement: End (right in LTR, left in RTL) &.offcanvas-end { inset-block: 0; inset-inline-end: 0; @@ -77,6 +97,7 @@ } } + // Placement: Top &.offcanvas-top { inset: 0 0 auto; height: var(--offcanvas-height); @@ -85,6 +106,7 @@ transform: translateY(-100%); } + // Placement: Bottom &.offcanvas-bottom { inset: auto 0 0; height: var(--offcanvas-height); @@ -93,6 +115,18 @@ transform: translateY(100%); } + // Fullscreen variant - covers entire viewport + &.offcanvas-fullscreen { + inset: 0; + width: 100%; + max-width: none; + height: 100%; + max-height: none; + border: 0; + transform: translateY(100%); + } + + // Show/hide states &.showing, &.show:not(.hiding) { transform: none; @@ -105,6 +139,7 @@ } } + // Above breakpoint - show content inline (for responsive offcanvas) @if not ($infix == "") { @include media-breakpoint-up($next) { --offcanvas-height: auto; @@ -116,11 +151,10 @@ } .offcanvas-body { - display: flex; flex-grow: 0; + flex-direction: row; padding: 0; overflow-y: visible; - // Reset `background-color` in case `.bg-*` classes are used in offcanvas background-color: transparent !important; // stylelint-disable-line declaration-no-important } } @@ -128,10 +162,12 @@ } } + // Backdrop overlay .offcanvas-backdrop { @include overlay-backdrop($zindex-offcanvas-backdrop, $offcanvas-backdrop-bg, $offcanvas-backdrop-opacity); } + // Header with close button .offcanvas-header { display: flex; align-items: center; @@ -139,7 +175,6 @@ .btn-close { padding: calc(var(--offcanvas-padding-y) * .5) calc(var(--offcanvas-padding-x) * .5); - // Split properties to avoid invalid calc() function if value is 0 margin-inline-start: auto; margin-inline-end: calc(-.5 * var(--offcanvas-padding-x)); margin-top: calc(-.5 * var(--offcanvas-padding-y)); @@ -147,14 +182,31 @@ } } + // Title .offcanvas-title { margin-bottom: 0; line-height: var(--offcanvas-title-line-height); } + // Scrollable body .offcanvas-body { + display: flex; flex-grow: 1; + flex-direction: column; + gap: var(--offcanvas-padding-y); padding: var(--offcanvas-padding-y) var(--offcanvas-padding-x); overflow-y: auto; } + + // Optional footer + .offcanvas-footer { + display: flex; + flex-shrink: 0; + flex-wrap: wrap; + gap: .5rem; + align-items: center; + justify-content: flex-end; + padding: var(--offcanvas-padding-y) var(--offcanvas-padding-x); + border-block-start: var(--offcanvas-border-width) solid var(--offcanvas-border-color); + } } diff --git a/scss/bootstrap.scss b/scss/bootstrap.scss index 441b6609b286..89f91122057f 100644 --- a/scss/bootstrap.scss +++ b/scss/bootstrap.scss @@ -24,6 +24,7 @@ @forward "dropdown"; @forward "list-group"; @forward "nav"; +@forward "nav-overflow"; @forward "navbar"; @forward "offcanvas"; @forward "pagination"; diff --git a/scss/content/_prose.scss b/scss/content/_prose.scss index 10215f176189..d887c94e7644 100644 --- a/scss/content/_prose.scss +++ b/scss/content/_prose.scss @@ -77,7 +77,7 @@ h5, h6 { &:not(:first-child) { - margin-top: calc(var(--content-gap) * 1.25); + margin-top: var(--content-gap); } } diff --git a/scss/forms/_form-variables.scss b/scss/forms/_form-variables.scss index b4927460fed1..eaf497db14f3 100644 --- a/scss/forms/_form-variables.scss +++ b/scss/forms/_form-variables.scss @@ -2,7 +2,7 @@ @use "../colors" as *; @use "../variables" as *; -$control-min-height: 2.5rem !default; +$control-min-height: 2.25rem !default; $control-min-height-sm: 2rem !default; $control-min-height-lg: 3rem !default; $control-padding-y: .375rem !default; diff --git a/scss/layout/_breakpoints.scss b/scss/layout/_breakpoints.scss index 5ec006d3c027..0a872fbc0797 100644 --- a/scss/layout/_breakpoints.scss +++ b/scss/layout/_breakpoints.scss @@ -135,3 +135,136 @@ } } } + + +// Container queries +// +// Container queries allow elements to respond to the size of a containing element +// rather than the viewport. These mixins mirror the media-breakpoint-* mixins above. +// +// scss-docs-start container-query-mixins + +// Set an element as a query container. +// +// @include set-container(); // container-type: inline-size +// @include set-container(size); // container-type: size +// @include set-container(inline-size, sidebar); // container: sidebar / inline-size +// +@mixin set-container($type: inline-size, $name: null) { + @if $name { + container: #{$name} / #{$type}; + } @else { + container-type: #{$type}; + } +} + +// Container query of at least the minimum breakpoint width. No query for the smallest breakpoint. +// Makes the @content apply to the given breakpoint and wider within the container. +// +// @include container-breakpoint-up(md) { ... } +// @include container-breakpoint-up(lg, sidebar) { ... } // Query named container +// +@mixin container-breakpoint-up($name, $container-name: null, $breakpoints: $grid-breakpoints) { + $min: breakpoint-min($name, $breakpoints); + @if $min { + @if $container-name { + @container #{$container-name} (width >= #{$min}) { + @content; + } + } @else { + @container (width >= #{$min}) { + @content; + } + } + } @else { + @content; + } +} + +// Container query of at most the maximum breakpoint width. No query for the largest breakpoint. +// Makes the @content apply to the given breakpoint and narrower within the container. +// +// @include container-breakpoint-down(lg) { ... } +// @include container-breakpoint-down(lg, sidebar) { ... } // Query named container +// +@mixin container-breakpoint-down($name, $container-name: null, $breakpoints: $grid-breakpoints) { + $max: breakpoint-max($name, $breakpoints); + @if $max { + @if $container-name { + @container #{$container-name} (width < #{$max}) { + @content; + } + } @else { + @container (width < #{$max}) { + @content; + } + } + } @else { + @content; + } +} + +// Container query that spans multiple breakpoint widths. +// Makes the @content apply between the min and max breakpoints within the container. +// +// @include container-breakpoint-between(md, xl) { ... } +// @include container-breakpoint-between(md, xl, sidebar) { ... } // Query named container +// +@mixin container-breakpoint-between($lower, $upper, $container-name: null, $breakpoints: $grid-breakpoints) { + $min: breakpoint-min($lower, $breakpoints); + $max: breakpoint-max($upper, $breakpoints); + + @if $min != null and $max != null { + @if $container-name { + @container #{$container-name} (width >= #{$min}) and (width < #{$max}) { + @content; + } + } @else { + @container (width >= #{$min}) and (width < #{$max}) { + @content; + } + } + } @else if $max == null { + @include container-breakpoint-up($lower, $container-name, $breakpoints) { + @content; + } + } @else if $min == null { + @include container-breakpoint-down($upper, $container-name, $breakpoints) { + @content; + } + } +} + +// Container query between the breakpoint's minimum and maximum widths. +// No minimum for the smallest breakpoint, and no maximum for the largest one. +// Makes the @content apply only to the given breakpoint within the container. +// +// @include container-breakpoint-only(md) { ... } +// @include container-breakpoint-only(md, sidebar) { ... } // Query named container +// +@mixin container-breakpoint-only($name, $container-name: null, $breakpoints: $grid-breakpoints) { + $min: breakpoint-min($name, $breakpoints); + $next: breakpoint-next($name, $breakpoints); + $max: breakpoint-max($next, $breakpoints); + + @if $min != null and $max != null { + @if $container-name { + @container #{$container-name} (width >= #{$min}) and (width < #{$max}) { + @content; + } + } @else { + @container (width >= #{$min}) and (width < #{$max}) { + @content; + } + } + } @else if $max == null { + @include container-breakpoint-up($name, $container-name, $breakpoints) { + @content; + } + } @else if $min == null { + @include container-breakpoint-down($next, $container-name, $breakpoints) { + @content; + } + } +} +// scss-docs-end container-query-mixins diff --git a/site/data/sidebar.yml b/site/data/sidebar.yml index 1adbb4c977ed..f25eb46b9b8d 100644 --- a/site/data/sidebar.yml +++ b/site/data/sidebar.yml @@ -99,8 +99,9 @@ - title: Dialog - title: Dropdown - title: List group - - title: Navbar - title: Navs & tabs + - title: Nav overflow + - title: Navbar - title: Offcanvas - title: Pagination - title: Placeholder diff --git a/site/src/assets/examples/album-rtl/index.astro b/site/src/assets/examples/album-rtl/index.astro index a5e224a00b80..e71da5a4e6f0 100644 --- a/site/src/assets/examples/album-rtl/index.astro +++ b/site/src/assets/examples/album-rtl/index.astro @@ -32,7 +32,7 @@ export const direction = 'rtl' الألبوم diff --git a/site/src/assets/examples/album/index.astro b/site/src/assets/examples/album/index.astro index 204062c52a97..6fe3d5fe6843 100644 --- a/site/src/assets/examples/album/index.astro +++ b/site/src/assets/examples/album/index.astro @@ -31,7 +31,7 @@ import Placeholder from "@shortcodes/Placeholder.astro" Album diff --git a/site/src/assets/examples/carousel-rtl/index.astro b/site/src/assets/examples/carousel-rtl/index.astro index 6a981e49423c..e6086a301e46 100644 --- a/site/src/assets/examples/carousel-rtl/index.astro +++ b/site/src/assets/examples/carousel-rtl/index.astro @@ -10,7 +10,7 @@ import Placeholder from "@shortcodes/Placeholder.astro"
شرائح العرض