]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
More improvements
authorMark Otto <markdotto@gmail.com>
Sun, 1 Feb 2026 03:52:38 +0000 (19:52 -0800)
committerMark Otto <markdotto@gmail.com>
Sun, 1 Feb 2026 03:52:38 +0000 (19:52 -0800)
17 files changed:
js/index.esm.js
js/index.umd.js
js/src/dropdown.js
js/src/nav-overflow.js
js/tests/unit/dropdown.spec.js
js/tests/unit/nav-overflow.spec.js [new file with mode: 0644]
scss/_nav-overflow.scss
scss/_navbar.scss
scss/_offcanvas.scss
scss/content/_prose.scss
scss/forms/_form-variables.scss
scss/layout/_breakpoints.scss
site/src/content/docs/components/dropdown.mdx
site/src/content/docs/components/nav-overflow.mdx
site/src/content/docs/components/navbar.mdx
site/src/content/docs/layout/breakpoints.mdx
site/src/scss/_content.scss

index 01d298e05fce7d851e5a0fc0438daaea0c05a34c..e52911e2accd3bec4c07f78f9f0583014cb14b54 100644 (file)
@@ -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'
index 73f12b424edd2c2429f3279bc3277b7e6b1cda76..73e7b45c83d6960f44ce8de91eb35282a35b3c46 100644 (file)
@@ -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,
index 5bd5323aaa2902f5a2219dc87614476693ea7d7e..2053b46dc7a5670d17cc2f32dd52380eae89c1bc 100644 (file)
@@ -97,6 +97,7 @@ const triangleSign = (p1, p2, p3) =>
 const Default = {
   autoClose: true,
   boundary: 'clippingParents',
+  container: false,
   display: 'dynamic',
   offset: [0, 2],
   floatingConfig: null,
@@ -111,6 +112,7 @@ const Default = {
 const DefaultType = {
   autoClose: '(boolean|string)',
   boundary: '(string|element)',
+  container: '(string|element|boolean)',
   display: 'string',
   offset: '(array|string|function)',
   floatingConfig: '(null|object|function)',
@@ -147,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()
 
@@ -187,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
@@ -222,6 +230,7 @@ class Dropdown extends BaseComponent {
 
   dispose() {
     this._disposeFloating()
+    this._restoreMenuToOriginalParent()
     this._disposeMediaQueryListeners()
     this._closeAllSubmenus()
     this._clearAllSubmenuTimeouts()
@@ -254,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)
@@ -454,6 +466,38 @@ 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, strategy = 'absolute') {
     if (!floating.isConnected) {
index c723371d8a189d57e94cf31685bed2c9efb64b10..7e0ef54c70254192b76e9e920f5dcd1f780ea882 100644 (file)
@@ -8,7 +8,6 @@
 import BaseComponent from './base-component.js'
 import EventHandler from './dom/event-handler.js'
 import SelectorEngine from './dom/selector-engine.js'
-import Dropdown from './dropdown.js'
 
 /**
  * Constants
@@ -134,7 +133,7 @@ class NavOverflow extends BaseComponent {
     const overflowItem = document.createElement('li')
     overflowItem.className = 'nav-item nav-overflow-item dropdown'
     overflowItem.innerHTML = `
-      <button class="nav-link nav-overflow-toggle dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+      <button class="nav-link nav-overflow-toggle dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-container="body" data-bs-strategy="fixed" aria-expanded="false">
         <span class="nav-overflow-icon">${this._config.moreIcon}</span>
         <span class="nav-overflow-text">${this._config.moreText}</span>
       </button>
@@ -144,11 +143,6 @@ class NavOverflow extends BaseComponent {
     this._element.append(overflowItem)
     this._overflowToggle = overflowItem.querySelector(SELECTOR_OVERFLOW_TOGGLE)
     this._overflowMenu = overflowItem.querySelector(SELECTOR_OVERFLOW_MENU)
-
-    // Initialize dropdown with fixed strategy to escape overflow containers
-    Dropdown.getOrCreateInstance(this._overflowToggle, {
-      strategy: 'fixed'
-    })
   }
 
   _setupResizeObserver() {
index 00462e1068a3dd9bc0592112cc0a2f3916eca649..9b79de40dc43fa05c383f2e8012190685f5593c9 100644 (file)
@@ -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 = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <div class="dropdown-menu">',
+          '    <a class="dropdown-item" href="#">Link</a>',
+          '  </div>',
+          '</div>'
+        ].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 = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <div class="dropdown-menu">',
+          '    <a class="dropdown-item" href="#">Link</a>',
+          '  </div>',
+          '</div>'
+        ].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 = [
+          '<div id="custom-container"></div>',
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <div class="dropdown-menu">',
+          '    <a class="dropdown-item" href="#">Link</a>',
+          '  </div>',
+          '</div>'
+        ].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 = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <div class="dropdown-menu">',
+          '    <a class="dropdown-item" href="#">Link</a>',
+          '  </div>',
+          '</div>'
+        ].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 = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-container="body">Dropdown</button>',
+          '  <div class="dropdown-menu">',
+          '    <a class="dropdown-item" href="#">Link</a>',
+          '  </div>',
+          '</div>'
+        ].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 (file)
index 0000000..89cad83
--- /dev/null
@@ -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 = [
+        '<ul class="nav" data-bs-toggle="nav-overflow">',
+        '  <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
+        '  <li class="nav-item"><a class="nav-link" href="#">Link 2</a></li>',
+        '</ul>'
+      ].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 = [
+        '<ul class="nav" data-bs-toggle="nav-overflow">',
+        '  <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
+        '</ul>'
+      ].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 = [
+        '<ul class="nav" data-bs-toggle="nav-overflow">',
+        '  <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
+        '</ul>'
+      ].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 = [
+        '<ul class="nav" data-bs-toggle="nav-overflow">',
+        '  <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
+        '  <li class="nav-item"><a class="nav-link" href="#">Link 2</a></li>',
+        '  <li class="nav-item"><a class="nav-link" href="#">Link 3</a></li>',
+        '</ul>'
+      ].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 = [
+        '<ul class="nav" data-bs-toggle="nav-overflow">',
+        '  <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
+        '</ul>'
+      ].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 = [
+          '<ul class="nav" data-bs-toggle="nav-overflow">',
+          '  <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
+          '</ul>'
+        ].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 = [
+        '<ul class="nav" data-bs-toggle="nav-overflow">',
+        '  <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
+        '</ul>'
+      ].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 = [
+        '<ul class="nav" data-bs-toggle="nav-overflow">',
+        '  <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
+        '</ul>'
+      ].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 = '<ul class="nav"></ul>'
+
+      const navEl = fixtureEl.querySelector('.nav')
+
+      expect(NavOverflow.getInstance(navEl)).toBeNull()
+    })
+  })
+
+  describe('getOrCreateInstance', () => {
+    it('should return nav overflow instance', () => {
+      fixtureEl.innerHTML = [
+        '<ul class="nav" data-bs-toggle="nav-overflow">',
+        '  <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
+        '</ul>'
+      ].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 = [
+        '<ul class="nav">',
+        '  <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
+        '</ul>'
+      ].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 = [
+        '<ul class="nav" data-bs-toggle="nav-overflow">',
+        '  <li class="nav-item"><a class="nav-link" href="#">Link 1</a></li>',
+        '</ul>'
+      ].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 = [
+        '<ul class="nav" style="width: 100px;" data-bs-toggle="nav-overflow">',
+        '  <li class="nav-item nav-overflow-keep"><a class="nav-link" href="#">Keep</a></li>',
+        '  <li class="nav-item"><a class="nav-link" href="#">Link 2</a></li>',
+        '</ul>'
+      ].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()
+    })
+  })
+})
index 862c6f280d30add11d6f94e983420639d8855d88..fb85ef3ed156fc932581e77be625f9fe91f1e971 100644 (file)
@@ -9,6 +9,7 @@
 @layer components {
   .nav-overflow {
     flex-wrap: nowrap;
+    min-width: 0; // Allow flex child to shrink below content width
   }
 
   // Container item for overflow
index 2a21fc77ed9589fb25759d2fdab791de786c9997..005abdbb1e4d46bf18938eac35dab5fa629ec69f 100644 (file)
@@ -21,6 +21,7 @@ $navbar-brand-height:               1.5rem !default;
 $navbar-brand-padding-y:            $navbar-brand-height * .5 !default;
 $navbar-brand-margin-end:           1rem !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;
@@ -63,6 +64,7 @@ $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};
@@ -77,7 +79,7 @@ $navbar-dark-brand-hover-color:     $navbar-dark-active-color !default;
     align-items: center;
     justify-content: space-between;
     padding: var(--navbar-padding-y) var(--navbar-padding-x);
-    container-type: inline-size; // Enable container queries for responsive behavior
+    @include set-container();
     @include gradient-bg();
 
     // Container properties for nested containers
@@ -181,12 +183,18 @@ $navbar-dark-brand-hover-color:     $navbar-dark-active-color !default;
   // 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;
-    border: var(--border-width) solid var(--navbar-toggler-border-color);
+    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));
 
@@ -203,10 +211,9 @@ $navbar-dark-brand-hover-color:     $navbar-dark-active-color !default;
   // Navbar toggler icon (inline SVG)
   .navbar-toggler-icon {
     display: inline-block;
-    width: 1em;
-    height: 1em;
+    width: 1rem;
+    height: 1rem;
     color: var(--navbar-color);
-    vertical-align: -.125em;
   }
 
 
@@ -256,6 +263,7 @@ $navbar-dark-brand-hover-color:     $navbar-dark-active-color !default;
       .offcanvas-body {
         display: flex;
         flex-grow: 0;
+        flex-direction: row;
         align-items: center;
         padding: 0;
         overflow-y: visible;
@@ -276,11 +284,10 @@ $navbar-dark-brand-hover-color:     $navbar-dark-active-color !default;
   @each $breakpoint in map.keys($grid-breakpoints) {
     $next: breakpoint-next($breakpoint, $grid-breakpoints);
     $infix: breakpoint-infix($next, $grid-breakpoints);
-    $min-width: breakpoint-min($next, $grid-breakpoints);
 
-    @if $next and $min-width {
+    @if $next {
       .navbar-expand#{$infix} {
-        @container (min-width: #{$min-width}) {
+        @include container-breakpoint-up($next) {
           @include navbar-expanded();
         }
       }
index 8d6ad0dcb52225903f62e0fd941abac40d122720..4ce9ada18b83c07da7063b2e18d15162b025f101 100644 (file)
@@ -151,8 +151,8 @@ $offcanvas-backdrop-opacity:        .5 !default;
           }
 
           .offcanvas-body {
-            display: flex;
             flex-grow: 0;
+            flex-direction: row;
             padding: 0;
             overflow-y: visible;
             background-color: transparent !important; // stylelint-disable-line declaration-no-important
@@ -190,7 +190,10 @@ $offcanvas-backdrop-opacity:        .5 !default;
 
   // 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;
   }
index 10215f17618981bec4e687848037c0084fcd266f..d887c94e764482f81f920e708c6c9afcc0d7397b 100644 (file)
@@ -77,7 +77,7 @@
     h5,
     h6 {
       &:not(:first-child) {
-        margin-top: calc(var(--content-gap) * 1.25);
+        margin-top: var(--content-gap);
       }
     }
 
index b4927460fed1677403de8e3403383cf32c5c1dbd..eaf497db14f3009f2010c5452daca764c35b00cb 100644 (file)
@@ -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;
index 5ec006d3c02761c096cf78a0ebeb9f9a50483bee..0a872fbc07972a88230b38e3858ed20823f24fb3 100644 (file)
     }
   }
 }
+
+
+// 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
index d2d26925f09d30770d9ea7d06319c0b70edbaf64..eb04349e2c4c3bf30ef76dec221bb24af39c071b 100644 (file)
@@ -582,6 +582,7 @@ The dropdown plugin requires the following JavaScript files if you're building B
 | --- | --- | --- | --- |
 | `autoClose` | boolean, string | `true` | Configure the auto close behavior of the dropdown: <ul class="my-2"><li>`true` - the dropdown will be closed by clicking outside or inside the dropdown menu.</li><li>`false` - the dropdown will be closed by clicking the toggle button and manually calling `hide` or `toggle` method. (Also will not be closed by pressing <kbd>Esc</kbd> key)</li><li>`'inside'` - the dropdown will be closed (only) by clicking inside the dropdown menu.</li> <li>`'outside'` - the dropdown will be closed (only) by clicking outside the dropdown menu.</li></ul> Note: the dropdown can always be closed with the <kbd>Esc</kbd> key. |
 | `boundary` | string, element | `'clippingParents'` | Overflow constraint boundary of the dropdown menu (applies only to the shift middleware). By default it's `clippingParents` and can accept an HTMLElement reference (via JavaScript only). For more information refer to Floating UI's [shift docs](https://floating-ui.com/docs/shift). |
+| `container` | string, element, boolean | `false` | Appends the dropdown menu to a specific element when shown. Use `'body'` or `true` to append to the document body, which helps escape containers with `overflow: hidden`. The menu is moved back to its original position when hidden. |
 | `display` | string | `'dynamic'` | By default, we use Floating UI for dynamic positioning. Disable this with `static`. |
 | `offset` | array, string, function | `[0, 2]` | Offset of the dropdown relative to its target. You can pass a string in data attributes with comma separated values like: `data-bs-offset="10,20"`. When a function is used to determine the offset, it is called with an object containing the placement, the reference, and floating rects as its first argument. The triggering element DOM node is passed as the second argument. The function must return an array with two numbers: [skidding, distance]. For more information refer to Floating UI's [offset docs](https://floating-ui.com/docs/offset). |
 | `floatingConfig` | null, object, function | `null` | To change Bootstrap's default Floating UI config, see [Floating UI's configuration](https://floating-ui.com/docs/computePosition). When a function is used to create the Floating UI configuration, it's called with an object that contains the Bootstrap's default Floating UI configuration. It helps you use and merge the default with your own configuration. The function must return a configuration object for Floating UI. |
index 75838c34dca693bb05303dbcbb87667e8031c044..542e27efa146877ba5ba96971131893b585edde2 100644 (file)
@@ -160,7 +160,7 @@ The nav overflow pattern can also be used within a [navbar]([[docsref:/component
 <ResizableExample code={`<nav class="navbar navbar-expand bg-body-tertiary">
     <div class="container-fluid">
       <a class="navbar-brand" href="#">Brand</a>
-      <ul class="navbar-nav" data-bs-toggle="nav-overflow">
+      <ul class="nav navbar-nav" data-bs-toggle="nav-overflow">
         <li class="nav-item">
           <a class="nav-link active" aria-current="page" href="#">Home</a>
         </li>
index f61d5ab48784d73905ba25482e7a5b92ae630e2e..e1dcf8587b6360bdf482c471d3e189eaf35948f8 100644 (file)
@@ -10,7 +10,7 @@ import { getConfig } from '@libs/config'
 
 Here's what you need to know before getting started with the navbar:
 
-- Navbars require a wrapping `.navbar` with `.navbar-expand-lg{-sm|-md|-lg|-xl|-2xl}` for responsive collapsing and [color scheme](#color-schemes) classes.
+- Navbars require a wrapping `.navbar` with `.navbar-expand-{breakpoint}` for responsive collapsing and [color scheme](#color-schemes) classes.
 - Navbars and their contents are fluid by default. Change the [container](#containers) to limit their horizontal width in different ways.
 - Use our [margin]([[docsref:/utilities/margin]]), [padding]([[docsref:/utilities/padding]]), and [flex]([[docsref:/utilities/flex]]) utility classes for controlling spacing and alignment within navbars.
 - Navbars are responsive by default using our **offcanvas component**. On mobile, navigation links slide in from the side as a drawer.
@@ -43,7 +43,7 @@ Here's an example of all the sub-components included in a responsive light-theme
           <CloseButton dismiss="offcanvas" />
         </div>
         <div class="offcanvas-body mb-2 mb-md-0">
-          <ul class="nav nav-pills me-auto">
+          <ul class="nav navbar-nav me-auto">
             <li class="nav-item">
               <a class="nav-link active" aria-current="page" href="#">Home</a>
             </li>
index 50c74bf2d8c2e7d84031aa96479937a5361c1a9d..79bc2b1e0ced0efe702e6c8fe7b6e1080e3038e7 100644 (file)
@@ -165,3 +165,153 @@ Which results in:
 // Apply styles starting from medium devices and up to extra large devices
 @media (width >= 768px) and (width < 1200px) { ... }
 ```
+
+## Container queries
+
+In addition to viewport-based media queries, Bootstrap uses [container queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_containment/Container_queries) for certain components. Container queries allow elements to respond to the size of a parent element rather than the viewport, enabling more flexible and modular responsive behavior.
+
+Container queries use the same breakpoint values as viewport media queries.
+
+### Usage in Bootstrap
+
+The following components use container queries:
+
+- **[Navbar]([[docsref:/components/navbar]])** — Uses container queries for its responsive expand behavior. The `.navbar` element is set as a query container, and the `.navbar-expand-*` classes use `@container` queries instead of `@media` queries. This means the navbar responds to its own width rather than the viewport width, making it more adaptable when placed in different layout contexts (e.g., within a sidebar or constrained container).
+
+- **[Stepper]([[docsref:/components/stepper]])** — The `.stepper-overflow` wrapper uses `container-type: inline-size` to establish a containment context for the horizontally scrolling stepper pattern.
+
+Here's how the navbar implements container queries:
+
+```scss
+// The navbar is defined as a query container
+.navbar {
+  container-type: inline-size;
+}
+
+// Responsive classes use container queries
+.navbar-expand-lg {
+  @container (min-width: 1024px) {
+    // Expanded navbar styles...
+  }
+}
+```
+
+### Setting a container
+
+Use the `set-container()` mixin to establish an element as a query container:
+
+```scss
+// Default: inline-size containment
+.my-component {
+  @include set-container();
+  // Output: container-type: inline-size;
+}
+
+// With explicit type
+.my-component {
+  @include set-container(size);
+  // Output: container-type: size;
+}
+
+// With a name (uses shorthand property)
+.my-component {
+  @include set-container(inline-size, sidebar);
+  // Output: container: sidebar / inline-size;
+}
+```
+
+### Container query mixins
+
+Similar to the `media-breakpoint-*` mixins for viewport-based media queries, Bootstrap provides container query mixins that use the same breakpoint values and range syntax.
+
+#### Min-width
+
+Use `container-breakpoint-up()` to apply styles when the container is at least the given breakpoint width:
+
+```scss
+.my-component {
+  @include set-container();
+}
+
+.my-component-child {
+  // …
+
+  @include container-breakpoint-up(md) {
+    // Styles for when container is at least 768px wide
+  }
+
+  @include container-breakpoint-up(lg) {
+    // Styles for when container is at least 1024px wide
+  }
+}
+```
+
+These mixins use the same modern range syntax as media queries. For example:
+
+```scss
+@container (width >= 768px) { ... }
+@container (width >= 1024px) { ... }
+```
+
+#### Max-width
+
+Use `container-breakpoint-down()` to apply styles when the container is narrower than the given breakpoint:
+
+```scss
+// Sass usage
+@include container-breakpoint-down(lg) { ... }
+
+// Compiled CSS
+@container (width < 1024px) { ... }
+```
+
+#### Single breakpoint
+
+Use `container-breakpoint-only()` to target a single breakpoint range:
+
+```scss
+// Sass usage
+@include container-breakpoint-only(md) { ... }
+
+// Compiled CSS
+@container (width >= 768px) and (width < 1024px) { ... }
+```
+
+#### Between breakpoints
+
+Use `container-breakpoint-between()` to span multiple breakpoint widths:
+
+```scss
+// Sass usage
+@include container-breakpoint-between(md, xl) { ... }
+
+// Compiled CSS
+@container (width >= 768px) and (width < 1280px) { ... }
+```
+
+### Named containers
+
+All container query mixins accept an optional container name parameter for querying a specific ancestor container. This is useful when you have nested containers:
+
+```scss
+.sidebar {
+  @include set-container(inline-size, sidebar);
+}
+
+.main-content {
+  @include set-container(inline-size, main);
+}
+
+// Query the sidebar container specifically, even if nested inside main
+.widget {
+  @include container-breakpoint-up(md, sidebar) {
+    // …
+  }
+}
+```
+
+The compiled output includes the container name:
+
+```scss
+@container sidebar (width >= 768px) { ... }
+```
index b5c3109424f92819e1c64d9abd8b10469440a51a..9ad8766848377d2ca300bac3b8e50f1129844ade 100644 (file)
   }
 
   .bd-subtitle {
-    font-size: 1.5rem;
+    font-size: var(--font-size-lg);
     font-weight: 300;
   }