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"
شرائح العرض
diff --git a/site/src/assets/examples/carousel/index.astro b/site/src/assets/examples/carousel/index.astro
index 2ca4339be028..8660d7458d4c 100644
--- a/site/src/assets/examples/carousel/index.astro
+++ b/site/src/assets/examples/carousel/index.astro
@@ -9,7 +9,7 @@ import Placeholder from "@shortcodes/Placeholder.astro"
Carousel
diff --git a/site/src/assets/examples/cheatsheet-rtl/index.astro b/site/src/assets/examples/cheatsheet-rtl/index.astro
index 2c0909adfd5e..bb80e8d6be0e 100644
--- a/site/src/assets/examples/cheatsheet-rtl/index.astro
+++ b/site/src/assets/examples/cheatsheet-rtl/index.astro
@@ -1186,7 +1186,7 @@ import Placeholder from "@shortcodes/Placeholder.astro"
@@ -1225,7 +1225,7 @@ import Placeholder from "@shortcodes/Placeholder.astro"
diff --git a/site/src/assets/examples/cheatsheet/index.astro b/site/src/assets/examples/cheatsheet/index.astro
index 01d0af1b8ee4..0b091ba283d8 100644
--- a/site/src/assets/examples/cheatsheet/index.astro
+++ b/site/src/assets/examples/cheatsheet/index.astro
@@ -1182,7 +1182,7 @@ export const body_class = 'bg-body-tertiary'
@@ -1221,7 +1221,7 @@ export const body_class = 'bg-body-tertiary'
diff --git a/site/src/assets/examples/navbar-bottom/index.astro b/site/src/assets/examples/navbar-bottom/index.astro
index 35aa348c69b2..c3889724f051 100644
--- a/site/src/assets/examples/navbar-bottom/index.astro
+++ b/site/src/assets/examples/navbar-bottom/index.astro
@@ -15,7 +15,7 @@ export const title = 'Bottom navbar example'
Bottom navbar
diff --git a/site/src/assets/examples/navbar-fixed/index.astro b/site/src/assets/examples/navbar-fixed/index.astro
index 3524255c2f5f..a2d3c704ade4 100644
--- a/site/src/assets/examples/navbar-fixed/index.astro
+++ b/site/src/assets/examples/navbar-fixed/index.astro
@@ -9,7 +9,7 @@ export const extra_css = ['navbar-fixed.css']
Fixed navbar
diff --git a/site/src/assets/examples/navbar-static/index.astro b/site/src/assets/examples/navbar-static/index.astro
index 600b313ec5c1..fd606a659d18 100644
--- a/site/src/assets/examples/navbar-static/index.astro
+++ b/site/src/assets/examples/navbar-static/index.astro
@@ -9,7 +9,7 @@ export const extra_css = ['navbar-static.css']
Top navbar
diff --git a/site/src/assets/examples/navbars-offcanvas/index.astro b/site/src/assets/examples/navbars-offcanvas/index.astro
index ec6b03f76d92..327616db0815 100644
--- a/site/src/assets/examples/navbars-offcanvas/index.astro
+++ b/site/src/assets/examples/navbars-offcanvas/index.astro
@@ -10,7 +10,7 @@ export const extra_css = ['navbars-offcanvas.css']