]> git.ipfire.org Git - thirdparty/bootstrap.git/commitdiff
First pass at submenu support (#41967)
authorMark Otto <markd.otto@gmail.com>
Mon, 29 Dec 2025 01:45:22 +0000 (17:45 -0800)
committerGitHub <noreply@github.com>
Mon, 29 Dec 2025 01:45:22 +0000 (17:45 -0800)
* First pass at submenu support

* Remove unused constants

* Fix up linter errors

* Logical properties for placement

* Better docs playground for dropdowns

* refactor and update bundles

* more tests, fix broken tests

* more tests

* more

* more

.bundlewatch.config.json
js/src/dropdown.js
js/tests/unit/dropdown.spec.js
js/tests/visual/dropdown-submenu.html [new file with mode: 0644]
scss/_dropdown.scss
scss/_variables.scss
site/src/components/shortcodes/DropdownPlacementPlayground.astro [new file with mode: 0644]
site/src/content/docs/components/dropdown.mdx
site/src/types/auto-import.d.ts

index 52a4dcfb6d27c07c0fd4f66f92c545c78a1de1bc..a57d7967064d51cc8c3631ec55158ed7bdb431ea 100644 (file)
     },
     {
       "path": "./dist/js/bootstrap.bundle.js",
-      "maxSize": "43.5 kB"
+      "maxSize": "47.0 kB"
     },
     {
       "path": "./dist/js/bootstrap.bundle.min.js",
-      "maxSize": "22.5 kB"
+      "maxSize": "24.0 kB"
     },
     {
       "path": "./dist/js/bootstrap.esm.js",
-      "maxSize": "30.0 kB"
+      "maxSize": "33.5 kB"
     },
     {
       "path": "./dist/js/bootstrap.esm.min.js",
-      "maxSize": "18.25 kB"
+      "maxSize": "20.0 kB"
     },
     {
       "path": "./dist/js/bootstrap.js",
-      "maxSize": "30.25 kB"
+      "maxSize": "33.75 kB"
     },
     {
       "path": "./dist/js/bootstrap.min.js",
-      "maxSize": "16.5 kB"
+      "maxSize": "18.5 kB"
     }
   ],
   "ci": {
index ecd0f48b05a9f989cbd8c2c1a1fadf0519848982..86955dd597385d91109da146f3874e641d31bd88 100644 (file)
@@ -46,7 +46,16 @@ const ESCAPE_KEY = 'Escape'
 const TAB_KEY = 'Tab'
 const ARROW_UP_KEY = 'ArrowUp'
 const ARROW_DOWN_KEY = 'ArrowDown'
-const RIGHT_MOUSE_BUTTON = 2 // MouseEvent.button value for the secondary button, usually the right button
+const ARROW_LEFT_KEY = 'ArrowLeft'
+const ARROW_RIGHT_KEY = 'ArrowRight'
+const HOME_KEY = 'Home'
+const END_KEY = 'End'
+const ENTER_KEY = 'Enter'
+const SPACE_KEY = ' '
+const RIGHT_MOUSE_BUTTON = 2
+
+// Hover intent delay (ms) - grace period before closing submenu
+const SUBMENU_CLOSE_DELAY = 100
 
 const EVENT_HIDE = `hide${EVENT_KEY}`
 const EVENT_HIDDEN = `hidden${EVENT_KEY}`
@@ -61,11 +70,29 @@ const CLASS_NAME_SHOW = 'show'
 const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="dropdown"]:not(.disabled):not(:disabled)'
 const SELECTOR_DATA_TOGGLE_SHOWN = `${SELECTOR_DATA_TOGGLE}.${CLASS_NAME_SHOW}`
 const SELECTOR_MENU = '.dropdown-menu'
+const SELECTOR_SUBMENU = '.dropdown-submenu'
+const SELECTOR_SUBMENU_TOGGLE = '.dropdown-submenu > .dropdown-item'
 const SELECTOR_NAVBAR_NAV = '.navbar-nav'
-const SELECTOR_VISIBLE_ITEMS = '.dropdown-menu .dropdown-item:not(.disabled):not(:disabled)'
+const SELECTOR_VISIBLE_ITEMS = '.dropdown-item:not(.disabled):not(:disabled)'
+
+// Default logical placement (uses start/end which get resolved to left/right based on RTL)
+const DEFAULT_PLACEMENT = 'bottom-start'
+const SUBMENU_PLACEMENT = 'end-start'
+
+// Resolve logical placement (start/end) to physical (left/right) based on RTL
+const resolveLogicalPlacement = placement => {
+  if (isRTL()) {
+    // RTL: start → right, end → left
+    return placement.replace(/^start(?=-|$)/, 'right').replace(/^end(?=-|$)/, 'left')
+  }
+
+  // LTR: start → left, end → right
+  return placement.replace(/^start(?=-|$)/, 'left').replace(/^end(?=-|$)/, 'right')
+}
 
-// Default placement with RTL support
-const DEFAULT_PLACEMENT = isRTL() ? 'bottom-end' : 'bottom-start'
+// Helper for barycentric coordinate calculation (point in triangle check)
+const triangleSign = (p1, p2, p3) =>
+  ((p1.x - p3.x) * (p2.y - p3.y)) - ((p2.x - p3.x) * (p1.y - p3.y))
 
 const Default = {
   autoClose: true,
@@ -74,7 +101,10 @@ const Default = {
   offset: [0, 2],
   floatingConfig: null,
   placement: DEFAULT_PLACEMENT,
-  reference: 'toggle'
+  reference: 'toggle',
+  // Submenu options
+  submenuTrigger: 'both', // 'click', 'hover', or 'both'
+  submenuDelay: SUBMENU_CLOSE_DELAY
 }
 
 const DefaultType = {
@@ -84,7 +114,9 @@ const DefaultType = {
   offset: '(array|string|function)',
   floatingConfig: '(null|object|function)',
   placement: 'string',
-  reference: '(string|element|object)'
+  reference: '(string|element|object)',
+  submenuTrigger: 'string',
+  submenuDelay: 'number'
 }
 
 /**
@@ -103,6 +135,11 @@ class Dropdown extends BaseComponent {
     this._mediaQueryListeners = []
     this._responsivePlacements = null
     this._parent = this._element.parentNode // dropdown wrapper
+    this._isSubmenu = this._parent.classList.contains('dropdown-submenu')
+    this._openSubmenus = new Map() // Map of submenu element -> cleanup function
+    this._submenuCloseTimeouts = new Map() // Map of submenu element -> timeout ID
+    this._hoverIntentData = null // For safe triangle calculation
+
     // TODO: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.3/forms/input-group/
     this._menu = SelectorEngine.next(this._element, SELECTOR_MENU)[0] ||
       SelectorEngine.prev(this._element, SELECTOR_MENU)[0] ||
@@ -110,6 +147,9 @@ class Dropdown extends BaseComponent {
 
     // Parse responsive placements on init
     this._parseResponsivePlacements()
+
+    // Set up submenu event listeners
+    this._setupSubmenuListeners()
   }
 
   // Getters
@@ -158,10 +198,11 @@ class Dropdown extends BaseComponent {
     }
 
     this._element.focus()
-    this._element.setAttribute('aria-expanded', true)
+    this._element.setAttribute('aria-expanded', 'true')
 
     this._menu.classList.add(CLASS_NAME_SHOW)
     this._element.classList.add(CLASS_NAME_SHOW)
+    this._parent.classList.add(CLASS_NAME_SHOW)
     EventHandler.trigger(this._element, EVENT_SHOWN, relatedTarget)
   }
 
@@ -180,6 +221,8 @@ class Dropdown extends BaseComponent {
   dispose() {
     this._disposeFloating()
     this._disposeMediaQueryListeners()
+    this._closeAllSubmenus()
+    this._clearAllSubmenuTimeouts()
     super.dispose()
   }
 
@@ -196,6 +239,9 @@ class Dropdown extends BaseComponent {
       return
     }
 
+    // Close all open submenus first
+    this._closeAllSubmenus()
+
     // If this is a touch-enabled device we remove the extra
     // empty mouseover listeners we added for iOS support
     if ('ontouchstart' in document.documentElement) {
@@ -208,6 +254,7 @@ class Dropdown extends BaseComponent {
 
     this._menu.classList.remove(CLASS_NAME_SHOW)
     this._element.classList.remove(CLASS_NAME_SHOW)
+    this._parent.classList.remove(CLASS_NAME_SHOW)
     this._element.setAttribute('aria-expanded', 'false')
     Manipulator.removeDataAttribute(this._menu, 'placement')
     Manipulator.removeDataAttribute(this._menu, 'display')
@@ -255,8 +302,7 @@ class Dropdown extends BaseComponent {
   }
 
   async _updateFloatingPosition(referenceElement = null) {
-    // Check if menu exists and is still in the DOM
-    if (!this._menu || !this._menu.isConnected) {
+    if (!this._menu) {
       return
     }
 
@@ -276,27 +322,12 @@ class Dropdown extends BaseComponent {
     const middleware = this._getFloatingMiddleware()
     const floatingConfig = this._getFloatingConfig(placement, middleware)
 
-    const { x, y, placement: finalPlacement } = await computePosition(
+    await this._applyFloatingPosition(
       referenceElement,
       this._menu,
-      floatingConfig
+      floatingConfig.placement,
+      floatingConfig.middleware
     )
-
-    // Menu may have been disposed during the async computePosition call
-    if (!this._menu || !this._menu.isConnected) {
-      return
-    }
-
-    // Apply position to dropdown menu
-    Object.assign(this._menu.style, {
-      position: 'absolute',
-      left: `${x}px`,
-      top: `${y}px`,
-      margin: '0'
-    })
-
-    // Set placement attribute for CSS styling
-    Manipulator.setDataAttribute(this._menu, 'placement', finalPlacement)
   }
 
   _isShown() {
@@ -305,11 +336,12 @@ class Dropdown extends BaseComponent {
 
   _getPlacement() {
     // If we have responsive placements, find the appropriate one for current viewport
-    if (this._responsivePlacements) {
-      return getResponsivePlacement(this._responsivePlacements, DEFAULT_PLACEMENT)
-    }
+    const placement = this._responsivePlacements ?
+      getResponsivePlacement(this._responsivePlacements, DEFAULT_PLACEMENT) :
+      this._config.placement
 
-    return this._config.placement
+    // Resolve logical placements (start/end) to physical (left/right) based on RTL
+    return resolveLogicalPlacement(placement)
   }
 
   _parseResponsivePlacements() {
@@ -335,21 +367,21 @@ class Dropdown extends BaseComponent {
   }
 
   _getOffset() {
-    const { offset } = this._config
+    const { offset: offsetConfig } = this._config
 
-    if (typeof offset === 'string') {
-      return offset.split(',').map(value => Number.parseInt(value, 10))
+    if (typeof offsetConfig === 'string') {
+      return offsetConfig.split(',').map(value => Number.parseInt(value, 10))
     }
 
-    if (typeof offset === 'function') {
+    if (typeof offsetConfig === 'function') {
       // Floating UI passes different args, adapt the interface for offset function callbacks
       return ({ placement, rects }) => {
-        const result = offset({ placement, reference: rects.reference, floating: rects.floating }, this._element)
+        const result = offsetConfig({ placement, reference: rects.reference, floating: rects.floating }, this._element)
         return result
       }
     }
 
-    return offset
+    return offsetConfig
   }
 
   _getFloatingMiddleware() {
@@ -418,8 +450,313 @@ class Dropdown extends BaseComponent {
     }
   }
 
+  // Shared helper for positioning any floating element
+  async _applyFloatingPosition(reference, floating, placement, middleware) {
+    if (!floating.isConnected) {
+      return null
+    }
+
+    const { x, y, placement: finalPlacement } = await computePosition(
+      reference,
+      floating,
+      { placement, middleware }
+    )
+
+    if (!floating.isConnected) {
+      return null
+    }
+
+    Object.assign(floating.style, {
+      position: 'absolute',
+      left: `${x}px`,
+      top: `${y}px`,
+      margin: '0'
+    })
+
+    Manipulator.setDataAttribute(floating, 'placement', finalPlacement)
+    return finalPlacement
+  }
+
+  // -------------------------------------------------------------------------
+  // Submenu handling
+  // -------------------------------------------------------------------------
+
+  _setupSubmenuListeners() {
+    // Set up hover listeners for submenu triggers
+    if (this._config.submenuTrigger === 'hover' || this._config.submenuTrigger === 'both') {
+      EventHandler.on(this._menu, 'mouseenter', SELECTOR_SUBMENU_TOGGLE, event => {
+        this._onSubmenuTriggerEnter(event)
+      })
+
+      EventHandler.on(this._menu, 'mouseleave', SELECTOR_SUBMENU, event => {
+        this._onSubmenuLeave(event)
+      })
+
+      // Track mouse movement for safe triangle calculation
+      EventHandler.on(this._menu, 'mousemove', event => {
+        this._trackMousePosition(event)
+      })
+    }
+
+    // Set up click listener for submenu triggers
+    if (this._config.submenuTrigger === 'click' || this._config.submenuTrigger === 'both') {
+      EventHandler.on(this._menu, 'click', SELECTOR_SUBMENU_TOGGLE, event => {
+        this._onSubmenuTriggerClick(event)
+      })
+    }
+  }
+
+  _onSubmenuTriggerEnter(event) {
+    const trigger = event.target.closest(SELECTOR_SUBMENU_TOGGLE)
+    if (!trigger) {
+      return
+    }
+
+    const submenuWrapper = trigger.closest(SELECTOR_SUBMENU)
+    const submenu = SelectorEngine.findOne(SELECTOR_MENU, submenuWrapper)
+    if (!submenu) {
+      return
+    }
+
+    // Cancel any pending close timeout for this submenu
+    this._cancelSubmenuCloseTimeout(submenu)
+
+    // Close other open submenus at the same level
+    this._closeSiblingSubmenus(submenuWrapper)
+
+    // Open this submenu
+    this._openSubmenu(trigger, submenu, submenuWrapper)
+  }
+
+  _onSubmenuLeave(event) {
+    const submenuWrapper = event.target.closest(SELECTOR_SUBMENU)
+    const submenu = SelectorEngine.findOne(SELECTOR_MENU, submenuWrapper)
+    if (!submenu || !this._openSubmenus.has(submenu)) {
+      return
+    }
+
+    // Check if we're moving toward the submenu (safe triangle)
+    if (this._isMovingTowardSubmenu(event, submenu)) {
+      return
+    }
+
+    // Schedule submenu close with delay
+    this._scheduleSubmenuClose(submenu, submenuWrapper)
+  }
+
+  _onSubmenuTriggerClick(event) {
+    const trigger = event.target.closest(SELECTOR_SUBMENU_TOGGLE)
+    if (!trigger) {
+      return
+    }
+
+    event.preventDefault()
+    event.stopPropagation()
+
+    const submenuWrapper = trigger.closest(SELECTOR_SUBMENU)
+    const submenu = SelectorEngine.findOne(SELECTOR_MENU, submenuWrapper)
+    if (!submenu) {
+      return
+    }
+
+    // Toggle submenu
+    if (this._openSubmenus.has(submenu)) {
+      this._closeSubmenu(submenu, submenuWrapper)
+    } else {
+      this._closeSiblingSubmenus(submenuWrapper)
+      this._openSubmenu(trigger, submenu, submenuWrapper)
+    }
+  }
+
+  _openSubmenu(trigger, submenu, submenuWrapper) {
+    if (this._openSubmenus.has(submenu)) {
+      return
+    }
+
+    // Set ARIA attributes
+    trigger.setAttribute('aria-expanded', 'true')
+    trigger.setAttribute('aria-haspopup', 'true')
+
+    // Position and show submenu
+    submenu.classList.add(CLASS_NAME_SHOW)
+    submenuWrapper.classList.add(CLASS_NAME_SHOW)
+
+    // Set up Floating UI positioning for submenu
+    const cleanup = this._createSubmenuFloating(trigger, submenu, submenuWrapper)
+    this._openSubmenus.set(submenu, cleanup)
+
+    // Set up mouseenter on submenu to cancel close timeout
+    EventHandler.on(submenu, 'mouseenter', () => {
+      this._cancelSubmenuCloseTimeout(submenu)
+    })
+  }
+
+  _closeSubmenu(submenu, submenuWrapper) {
+    if (!this._openSubmenus.has(submenu)) {
+      return
+    }
+
+    // Close any nested submenus first
+    const nestedSubmenus = SelectorEngine.find(`${SELECTOR_SUBMENU} ${SELECTOR_MENU}.${CLASS_NAME_SHOW}`, submenu)
+    for (const nested of nestedSubmenus) {
+      const nestedWrapper = nested.closest(SELECTOR_SUBMENU)
+      this._closeSubmenu(nested, nestedWrapper)
+    }
+
+    // Get the trigger
+    const trigger = SelectorEngine.findOne(SELECTOR_SUBMENU_TOGGLE, submenuWrapper)
+
+    // Clean up Floating UI
+    const cleanup = this._openSubmenus.get(submenu)
+    if (cleanup) {
+      cleanup()
+    }
+
+    this._openSubmenus.delete(submenu)
+
+    // Remove event listeners
+    EventHandler.off(submenu, 'mouseenter')
+
+    // Update ARIA and visibility
+    if (trigger) {
+      trigger.setAttribute('aria-expanded', 'false')
+    }
+
+    submenu.classList.remove(CLASS_NAME_SHOW)
+    submenuWrapper.classList.remove(CLASS_NAME_SHOW)
+
+    // Clear inline styles
+    submenu.style.position = ''
+    submenu.style.left = ''
+    submenu.style.top = ''
+    submenu.style.margin = ''
+  }
+
+  _closeAllSubmenus() {
+    for (const [submenu] of this._openSubmenus) {
+      const submenuWrapper = submenu.closest(SELECTOR_SUBMENU)
+      this._closeSubmenu(submenu, submenuWrapper)
+    }
+  }
+
+  _closeSiblingSubmenus(currentSubmenuWrapper) {
+    // Find all sibling submenu wrappers and close their menus
+    const parent = currentSubmenuWrapper.parentNode
+    const siblingSubmenus = SelectorEngine.find(`${SELECTOR_SUBMENU} > ${SELECTOR_MENU}.${CLASS_NAME_SHOW}`, parent)
+
+    for (const siblingMenu of siblingSubmenus) {
+      const siblingWrapper = siblingMenu.closest(SELECTOR_SUBMENU)
+      if (siblingWrapper !== currentSubmenuWrapper) {
+        this._closeSubmenu(siblingMenu, siblingWrapper)
+      }
+    }
+  }
+
+  _createSubmenuFloating(trigger, submenu, submenuWrapper) {
+    const referenceElement = submenuWrapper
+    const placement = resolveLogicalPlacement(SUBMENU_PLACEMENT)
+    const middleware = [
+      offset({ mainAxis: 0, crossAxis: -4 }),
+      flip({
+        fallbackPlacements: [
+          resolveLogicalPlacement('start-start'),
+          resolveLogicalPlacement('end-end'),
+          resolveLogicalPlacement('start-end')
+        ]
+      }),
+      shift({ padding: 8 })
+    ]
+
+    const updatePosition = () => this._applyFloatingPosition(referenceElement, submenu, placement, middleware)
+
+    updatePosition()
+    return autoUpdate(referenceElement, submenu, updatePosition)
+  }
+
+  _scheduleSubmenuClose(submenu, submenuWrapper) {
+    this._cancelSubmenuCloseTimeout(submenu)
+
+    const timeoutId = setTimeout(() => {
+      this._closeSubmenu(submenu, submenuWrapper)
+      this._submenuCloseTimeouts.delete(submenu)
+    }, this._config.submenuDelay)
+
+    this._submenuCloseTimeouts.set(submenu, timeoutId)
+  }
+
+  _cancelSubmenuCloseTimeout(submenu) {
+    const timeoutId = this._submenuCloseTimeouts.get(submenu)
+    if (timeoutId) {
+      clearTimeout(timeoutId)
+      this._submenuCloseTimeouts.delete(submenu)
+    }
+  }
+
+  _clearAllSubmenuTimeouts() {
+    for (const timeoutId of this._submenuCloseTimeouts.values()) {
+      clearTimeout(timeoutId)
+    }
+
+    this._submenuCloseTimeouts.clear()
+  }
+
+  // -------------------------------------------------------------------------
+  // Hover intent / Safe triangle
+  // -------------------------------------------------------------------------
+
+  _trackMousePosition(event) {
+    this._hoverIntentData = {
+      x: event.clientX,
+      y: event.clientY,
+      timestamp: Date.now()
+    }
+  }
+
+  _isMovingTowardSubmenu(event, submenu) {
+    if (!this._hoverIntentData) {
+      return false
+    }
+
+    const submenuRect = submenu.getBoundingClientRect()
+    const currentPos = { x: event.clientX, y: event.clientY }
+    const lastPos = { x: this._hoverIntentData.x, y: this._hoverIntentData.y }
+
+    // Create a triangle from current position to submenu edges
+    // The triangle represents the "safe zone" for diagonal movement
+    const isRtl = isRTL()
+
+    // Determine which edge of the submenu to target based on direction
+    const targetX = isRtl ? submenuRect.right : submenuRect.left
+    const topCorner = { x: targetX, y: submenuRect.top }
+    const bottomCorner = { x: targetX, y: submenuRect.bottom }
+
+    // Check if cursor is moving toward the submenu
+    // by checking if the current position is within the safe triangle
+    return this._pointInTriangle(currentPos, lastPos, topCorner, bottomCorner)
+  }
+
+  _pointInTriangle(point, v1, v2, v3) {
+    // Barycentric coordinate method to check if point is inside triangle
+    const d1 = triangleSign(point, v1, v2)
+    const d2 = triangleSign(point, v2, v3)
+    const d3 = triangleSign(point, v3, v1)
+
+    const hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0)
+    const hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0)
+
+    return !(hasNeg && hasPos)
+  }
+
+  // -------------------------------------------------------------------------
+  // Keyboard navigation
+  // -------------------------------------------------------------------------
+
   _selectMenuItem({ key, target }) {
-    const items = SelectorEngine.find(SELECTOR_VISIBLE_ITEMS, this._menu).filter(element => isVisible(element))
+    // Get items only from the current menu level (not nested submenus)
+    // If target is inside a menu, use that menu; otherwise use the main menu
+    const currentMenu = target.closest(SELECTOR_MENU) || this._menu
+    const items = SelectorEngine.find(`:scope > li > ${SELECTOR_VISIBLE_ITEMS}, :scope > ${SELECTOR_VISIBLE_ITEMS}`, currentMenu)
+      .filter(element => isVisible(element))
 
     if (!items.length) {
       return
@@ -430,6 +767,99 @@ class Dropdown extends BaseComponent {
     getNextActiveElement(items, target, key === ARROW_DOWN_KEY, !items.includes(target)).focus()
   }
 
+  _handleSubmenuKeydown(event) {
+    const { key, target } = event
+    const isRtl = isRTL()
+
+    // Determine the "enter submenu" and "exit submenu" keys based on RTL
+    const enterKey = isRtl ? ARROW_LEFT_KEY : ARROW_RIGHT_KEY
+    const exitKey = isRtl ? ARROW_RIGHT_KEY : ARROW_LEFT_KEY
+
+    // Check if target is a submenu trigger
+    const submenuWrapper = target.closest(SELECTOR_SUBMENU)
+    const isSubmenuTrigger = submenuWrapper && target.matches(SELECTOR_SUBMENU_TOGGLE)
+
+    // Handle Enter/Space on submenu trigger
+    if ((key === ENTER_KEY || key === SPACE_KEY) && isSubmenuTrigger) {
+      event.preventDefault()
+      event.stopPropagation()
+
+      const submenu = SelectorEngine.findOne(SELECTOR_MENU, submenuWrapper)
+      if (submenu) {
+        this._closeSiblingSubmenus(submenuWrapper)
+        this._openSubmenu(target, submenu, submenuWrapper)
+        // Focus first item in submenu
+        requestAnimationFrame(() => {
+          const firstItem = SelectorEngine.findOne(SELECTOR_VISIBLE_ITEMS, submenu)
+          if (firstItem) {
+            firstItem.focus()
+          }
+        })
+      }
+
+      return true
+    }
+
+    // Handle Right arrow (or Left in RTL) - enter submenu
+    if (key === enterKey && isSubmenuTrigger) {
+      event.preventDefault()
+      event.stopPropagation()
+
+      const submenu = SelectorEngine.findOne(SELECTOR_MENU, submenuWrapper)
+      if (submenu) {
+        this._closeSiblingSubmenus(submenuWrapper)
+        this._openSubmenu(target, submenu, submenuWrapper)
+        // Focus first item in submenu
+        requestAnimationFrame(() => {
+          const firstItem = SelectorEngine.findOne(SELECTOR_VISIBLE_ITEMS, submenu)
+          if (firstItem) {
+            firstItem.focus()
+          }
+        })
+      }
+
+      return true
+    }
+
+    // Handle Left arrow (or Right in RTL) - exit submenu
+    if (key === exitKey) {
+      const currentMenu = target.closest(SELECTOR_MENU)
+      const parentSubmenuWrapper = currentMenu?.closest(SELECTOR_SUBMENU)
+
+      if (parentSubmenuWrapper) {
+        event.preventDefault()
+        event.stopPropagation()
+
+        const parentTrigger = SelectorEngine.findOne(SELECTOR_SUBMENU_TOGGLE, parentSubmenuWrapper)
+        this._closeSubmenu(currentMenu, parentSubmenuWrapper)
+        if (parentTrigger) {
+          parentTrigger.focus()
+        }
+
+        return true
+      }
+    }
+
+    // Handle Home/End keys
+    if (key === HOME_KEY || key === END_KEY) {
+      event.preventDefault()
+      event.stopPropagation()
+
+      const currentMenu = target.closest(SELECTOR_MENU)
+      const items = SelectorEngine.find(`:scope > li > ${SELECTOR_VISIBLE_ITEMS}, :scope > ${SELECTOR_VISIBLE_ITEMS}`, currentMenu)
+        .filter(element => isVisible(element))
+
+      if (items.length) {
+        const targetItem = key === HOME_KEY ? items[0] : items[items.length - 1]
+        targetItem.focus()
+      }
+
+      return true
+    }
+
+    return false
+  }
+
   static clearMenus(event) {
     if (event.button === RIGHT_MOUSE_BUTTON || (event.type === 'keyup' && event.key !== TAB_KEY)) {
       return
@@ -469,14 +899,19 @@ class Dropdown extends BaseComponent {
   }
 
   static dataApiKeydownHandler(event) {
-    // If not an UP | DOWN | ESCAPE key => not a dropdown command
-    // If input/textarea && if key is other than ESCAPE => not a dropdown command
-
+    // If not a relevant key => not a dropdown command
     const isInput = /input|textarea/i.test(event.target.tagName)
     const isEscapeEvent = event.key === ESCAPE_KEY
     const isUpOrDownEvent = [ARROW_UP_KEY, ARROW_DOWN_KEY].includes(event.key)
+    const isLeftOrRightEvent = [ARROW_LEFT_KEY, ARROW_RIGHT_KEY].includes(event.key)
+    const isHomeOrEndEvent = [HOME_KEY, END_KEY].includes(event.key)
+    const isEnterOrSpaceEvent = [ENTER_KEY, SPACE_KEY].includes(event.key)
 
-    if (!isUpOrDownEvent && !isEscapeEvent) {
+    // Allow Enter/Space only on submenu triggers
+    const isSubmenuTrigger = event.target.matches(SELECTOR_SUBMENU_TOGGLE)
+
+    if (!isUpOrDownEvent && !isEscapeEvent && !isLeftOrRightEvent && !isHomeOrEndEvent &&
+        !(isEnterOrSpaceEvent && isSubmenuTrigger)) {
       return
     }
 
@@ -484,8 +919,6 @@ class Dropdown extends BaseComponent {
       return
     }
 
-    event.preventDefault()
-
     // TODO: v6 revert #37011 & change markup https://getbootstrap.com/docs/5.3/forms/input-group/
     const getToggleButton = this.matches(SELECTOR_DATA_TOGGLE) ?
       this :
@@ -493,17 +926,46 @@ class Dropdown extends BaseComponent {
         SelectorEngine.next(this, SELECTOR_DATA_TOGGLE)[0] ||
         SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, event.delegateTarget.parentNode))
 
+    if (!getToggleButton) {
+      return
+    }
+
     const instance = Dropdown.getOrCreateInstance(getToggleButton)
 
+    // Handle submenu navigation first
+    if ((isLeftOrRightEvent || isHomeOrEndEvent || (isEnterOrSpaceEvent && isSubmenuTrigger)) && instance._handleSubmenuKeydown(event)) {
+      return
+    }
+
+    // Handle Up/Down navigation
     if (isUpOrDownEvent) {
+      event.preventDefault()
       event.stopPropagation()
       instance.show()
       instance._selectMenuItem(event)
       return
     }
 
-    if (instance._isShown()) { // else is escape and we check if it is shown
+    // Handle Escape
+    if (isEscapeEvent && instance._isShown()) {
+      event.preventDefault()
       event.stopPropagation()
+
+      // If in a submenu, close just that submenu
+      const currentMenu = event.target.closest(SELECTOR_MENU)
+      const parentSubmenuWrapper = currentMenu?.closest(SELECTOR_SUBMENU)
+
+      if (parentSubmenuWrapper && instance._openSubmenus.size > 0) {
+        const parentTrigger = SelectorEngine.findOne(SELECTOR_SUBMENU_TOGGLE, parentSubmenuWrapper)
+        instance._closeSubmenu(currentMenu, parentSubmenuWrapper)
+        if (parentTrigger) {
+          parentTrigger.focus()
+        }
+
+        return
+      }
+
+      // Otherwise close the whole dropdown
       instance.hide()
       getToggleButton.focus()
     }
index 37d7cb83d818bef1242a883adc0cc7668c916d95..f3429d89a6e44fd5cd70b759e90bac8d96c6e9e2 100644 (file)
@@ -2471,4 +2471,1452 @@ describe('Dropdown', () => {
       })
     })
   })
+
+  describe('submenu', () => {
+    it('should open submenu on click', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li><a class="dropdown-item" href="#">Action</a></li>',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action 1</a></li>',
+          '        <li><a class="dropdown-item" href="#">Sub-action 2</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          submenuTrigger.click()
+
+          expect(submenu.classList.contains('show')).toBeTrue()
+          expect(submenuWrapper.classList.contains('show')).toBeTrue()
+          resolve()
+        })
+
+        // eslint-disable-next-line no-new
+        new Dropdown(btnDropdown)
+        btnDropdown.click()
+      })
+    })
+
+    it('should toggle submenu on click', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu
+          submenuTrigger.click()
+          expect(submenu.classList.contains('show')).toBeTrue()
+
+          // Close submenu
+          submenuTrigger.click()
+          expect(submenu.classList.contains('show')).toBeFalse()
+          resolve()
+        })
+
+        // eslint-disable-next-line no-new
+        new Dropdown(btnDropdown)
+        btnDropdown.click()
+      })
+    })
+
+    it('should close sibling submenus when opening a new one', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu" id="submenu1">',
+          '      <button class="dropdown-item" type="button">Submenu 1</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Action 1</a></li>',
+          '      </ul>',
+          '    </li>',
+          '    <li class="dropdown-submenu" id="submenu2">',
+          '      <button class="dropdown-item" type="button">Submenu 2</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Action 2</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const submenu1Wrapper = fixtureEl.querySelector('#submenu1')
+        const submenu2Wrapper = fixtureEl.querySelector('#submenu2')
+        const submenu1Trigger = submenu1Wrapper.querySelector('.dropdown-item')
+        const submenu2Trigger = submenu2Wrapper.querySelector('.dropdown-item')
+        const submenu1 = submenu1Wrapper.querySelector('.dropdown-menu')
+        const submenu2 = submenu2Wrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open first submenu
+          submenu1Trigger.click()
+          expect(submenu1.classList.contains('show')).toBeTrue()
+          expect(submenu2.classList.contains('show')).toBeFalse()
+
+          // Open second submenu - first should close
+          submenu2Trigger.click()
+          expect(submenu1.classList.contains('show')).toBeFalse()
+          expect(submenu2.classList.contains('show')).toBeTrue()
+          resolve()
+        })
+
+        // eslint-disable-next-line no-new
+        new Dropdown(btnDropdown)
+        btnDropdown.click()
+      })
+    })
+
+    it('should open submenu with ArrowRight key', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Focus the submenu trigger
+          submenuTrigger.focus()
+
+          // Press ArrowRight to open submenu
+          const keydown = createEvent('keydown')
+          keydown.key = 'ArrowRight'
+          submenuTrigger.dispatchEvent(keydown)
+
+          setTimeout(() => {
+            // Submenu should be open
+            expect(submenu.classList.contains('show')).toBeTrue()
+            expect(submenuWrapper.classList.contains('show')).toBeTrue()
+            resolve()
+          }, 20)
+        })
+
+        // eslint-disable-next-line no-new
+        new Dropdown(btnDropdown)
+        btnDropdown.click()
+      })
+    })
+
+    it('should close submenu via internal method', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+        const dropdown = new Dropdown(btnDropdown)
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu first using internal method
+          dropdown._openSubmenu(submenuTrigger, submenu, submenuWrapper)
+          expect(submenu.classList.contains('show')).toBeTrue()
+          expect(dropdown._openSubmenus.size).toEqual(1)
+
+          // Close submenu using internal method
+          dropdown._closeSubmenu(submenu, submenuWrapper)
+          expect(submenu.classList.contains('show')).toBeFalse()
+          expect(dropdown._openSubmenus.size).toEqual(0)
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should open submenu with Enter key', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          submenuTrigger.focus()
+
+          const keydown = createEvent('keydown')
+          keydown.key = 'Enter'
+          submenuTrigger.dispatchEvent(keydown)
+
+          setTimeout(() => {
+            // Submenu should be open
+            expect(submenu.classList.contains('show')).toBeTrue()
+            expect(submenuWrapper.classList.contains('show')).toBeTrue()
+            resolve()
+          }, 20)
+        })
+
+        // eslint-disable-next-line no-new
+        new Dropdown(btnDropdown)
+        btnDropdown.click()
+      })
+    })
+
+    it('should open submenu with Space key', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          submenuTrigger.focus()
+
+          const keydown = createEvent('keydown')
+          keydown.key = ' '
+          submenuTrigger.dispatchEvent(keydown)
+
+          setTimeout(() => {
+            // Submenu should be open
+            expect(submenu.classList.contains('show')).toBeTrue()
+            expect(submenuWrapper.classList.contains('show')).toBeTrue()
+            resolve()
+          }, 20)
+        })
+
+        // eslint-disable-next-line no-new
+        new Dropdown(btnDropdown)
+        btnDropdown.click()
+      })
+    })
+
+    it('should close all submenus when main dropdown closes', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+        const dropdown = new Dropdown(btnDropdown)
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu
+          submenuTrigger.click()
+          expect(submenu.classList.contains('show')).toBeTrue()
+
+          // Close main dropdown
+          dropdown.hide()
+        })
+
+        btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+          expect(submenu.classList.contains('show')).toBeFalse()
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should close nested submenus when closing parent submenu', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu" id="level1">',
+          '      <button class="dropdown-item" type="button">Level 1</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li class="dropdown-submenu" id="level2">',
+          '          <button class="dropdown-item" type="button">Level 2</button>',
+          '          <ul class="dropdown-menu">',
+          '            <li><a class="dropdown-item" href="#">Level 3 action</a></li>',
+          '          </ul>',
+          '        </li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const level1Wrapper = fixtureEl.querySelector('#level1')
+        const level2Wrapper = fixtureEl.querySelector('#level2')
+        const level1Trigger = level1Wrapper.querySelector(':scope > .dropdown-item')
+        const level2Trigger = level2Wrapper.querySelector(':scope > .dropdown-item')
+        const level1Submenu = level1Wrapper.querySelector(':scope > .dropdown-menu')
+        const level2Submenu = level2Wrapper.querySelector(':scope > .dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open level 1
+          level1Trigger.click()
+          expect(level1Submenu.classList.contains('show')).toBeTrue()
+
+          // Open level 2
+          level2Trigger.click()
+          expect(level2Submenu.classList.contains('show')).toBeTrue()
+
+          // Close level 1 - level 2 should also close
+          level1Trigger.click()
+          expect(level1Submenu.classList.contains('show')).toBeFalse()
+          expect(level2Submenu.classList.contains('show')).toBeFalse()
+          resolve()
+        })
+
+        // eslint-disable-next-line no-new
+        new Dropdown(btnDropdown)
+        btnDropdown.click()
+      })
+    })
+
+    it('should have submenu items visible and focusable', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu" id="submenu">',
+          '        <li><a id="sub1" class="dropdown-item" href="#">Sub 1</a></li>',
+          '        <li><a id="sub2" class="dropdown-item" href="#">Sub 2</a></li>',
+          '        <li><a id="sub3" class="dropdown-item" href="#">Sub 3</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = fixtureEl.querySelector('#submenu')
+        const sub1 = fixtureEl.querySelector('#sub1')
+        const sub2 = fixtureEl.querySelector('#sub2')
+        const sub3 = fixtureEl.querySelector('#sub3')
+        const dropdown = new Dropdown(btnDropdown)
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu using internal method
+          dropdown._openSubmenu(submenuTrigger, submenu, submenuWrapper)
+
+          // Submenu items should be visible and focusable
+          expect(submenu.classList.contains('show')).toBeTrue()
+
+          sub1.focus()
+          expect(document.activeElement).toEqual(sub1)
+
+          sub2.focus()
+          expect(document.activeElement).toEqual(sub2)
+
+          sub3.focus()
+          expect(document.activeElement).toEqual(sub3)
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should close all submenus when hiding dropdown', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu" id="submenu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = fixtureEl.querySelector('#submenu')
+        const dropdown = new Dropdown(btnDropdown)
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu using internal method
+          dropdown._openSubmenu(submenuTrigger, submenu, submenuWrapper)
+          expect(submenu.classList.contains('show')).toBeTrue()
+          expect(dropdown._openSubmenus.size).toEqual(1)
+
+          // Hide the main dropdown
+          dropdown.hide()
+        })
+
+        btnDropdown.addEventListener('hidden.bs.dropdown', () => {
+          // All submenus should be closed
+          expect(submenu.classList.contains('show')).toBeFalse()
+          expect(dropdown._openSubmenus.size).toEqual(0)
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should respect submenuTrigger: click option', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-submenu-trigger="click">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+        const dropdown = new Dropdown(btnDropdown)
+
+        expect(dropdown._config.submenuTrigger).toEqual('click')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Click should work
+          submenuTrigger.click()
+          expect(submenu.classList.contains('show')).toBeTrue()
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should respect submenuTrigger: hover option', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-submenu-trigger="hover">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const dropdown = new Dropdown(btnDropdown)
+
+        expect(dropdown._config.submenuTrigger).toEqual('hover')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Hover should open submenu
+          const mouseenter = createEvent('mouseenter', { bubbles: true })
+          submenuTrigger.dispatchEvent(mouseenter)
+
+          // Note: In JSDOM, hover events may not work perfectly,
+          // but we verify the config is respected
+          expect(dropdown._config.submenuTrigger).toEqual('hover')
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should respect submenuDelay config option', () => {
+      fixtureEl.innerHTML = [
+        '<div class="dropdown">',
+        '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-submenu-delay="500">Dropdown</button>',
+        '  <ul class="dropdown-menu">',
+        '    <li class="dropdown-submenu">',
+        '      <button class="dropdown-item" type="button">More options</button>',
+        '      <ul class="dropdown-menu">',
+        '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+        '      </ul>',
+        '    </li>',
+        '  </ul>',
+        '</div>'
+      ].join('')
+
+      const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+      const dropdown = new Dropdown(btnDropdown)
+
+      expect(dropdown._config.submenuDelay).toEqual(500)
+    })
+
+    it('should position submenu using Floating UI', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu
+          submenuTrigger.click()
+
+          // Floating UI should set position styles
+          setTimeout(() => {
+            expect(submenu.style.position).toEqual('absolute')
+            expect(submenu.style.left).toBeTruthy()
+            expect(submenu.style.top).toBeTruthy()
+            resolve()
+          }, 50)
+        })
+
+        // eslint-disable-next-line no-new
+        new Dropdown(btnDropdown)
+        btnDropdown.click()
+      })
+    })
+
+    it('should set data-bs-placement attribute on submenu', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          submenuTrigger.click()
+
+          setTimeout(() => {
+            // Should have a placement data attribute
+            const placement = submenu.dataset.bsPlacement
+            expect(placement).toBeTruthy()
+            // Should be a valid placement
+            expect(['left-start', 'right-start', 'left-end', 'right-end', 'left', 'right'])
+              .toContain(placement)
+            resolve()
+          }, 50)
+        })
+
+        // eslint-disable-next-line no-new
+        new Dropdown(btnDropdown)
+        btnDropdown.click()
+      })
+    })
+
+    it('should cleanup Floating UI autoUpdate on submenu close', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown)
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu
+          submenuTrigger.click()
+          expect(dropdown._openSubmenus.size).toEqual(1)
+          expect(dropdown._openSubmenus.has(submenu)).toBeTrue()
+
+          // Close submenu
+          submenuTrigger.click()
+          expect(dropdown._openSubmenus.size).toEqual(0)
+          expect(dropdown._openSubmenus.has(submenu)).toBeFalse()
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should schedule submenu close with delay', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown, { submenuDelay: 50 })
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu
+          dropdown._openSubmenu(submenuTrigger, submenu, submenuWrapper)
+          expect(submenu.classList.contains('show')).toBeTrue()
+
+          // Schedule close
+          dropdown._scheduleSubmenuClose(submenu, submenuWrapper)
+          expect(dropdown._submenuCloseTimeouts.has(submenu)).toBeTrue()
+
+          // Still open immediately
+          expect(submenu.classList.contains('show')).toBeTrue()
+
+          // After delay, should be closed
+          setTimeout(() => {
+            expect(submenu.classList.contains('show')).toBeFalse()
+            expect(dropdown._submenuCloseTimeouts.has(submenu)).toBeFalse()
+            resolve()
+          }, 100)
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should cancel scheduled submenu close', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown, { submenuDelay: 50 })
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu
+          dropdown._openSubmenu(submenuTrigger, submenu, submenuWrapper)
+
+          // Schedule close
+          dropdown._scheduleSubmenuClose(submenu, submenuWrapper)
+          expect(dropdown._submenuCloseTimeouts.has(submenu)).toBeTrue()
+
+          // Cancel the close
+          dropdown._cancelSubmenuCloseTimeout(submenu)
+          expect(dropdown._submenuCloseTimeouts.has(submenu)).toBeFalse()
+
+          // After delay, should still be open
+          setTimeout(() => {
+            expect(submenu.classList.contains('show')).toBeTrue()
+            resolve()
+          }, 100)
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should clear all submenu timeouts on dispose', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown, { submenuDelay: 200 })
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu and schedule close
+          dropdown._openSubmenu(submenuTrigger, submenu, submenuWrapper)
+          dropdown._scheduleSubmenuClose(submenu, submenuWrapper)
+          expect(dropdown._submenuCloseTimeouts.size).toEqual(1)
+
+          // Clear all timeouts
+          dropdown._clearAllSubmenuTimeouts()
+          expect(dropdown._submenuCloseTimeouts.size).toEqual(0)
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should detect point inside triangle', () => {
+      fixtureEl.innerHTML = [
+        '<div class="dropdown">',
+        '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+        '  <ul class="dropdown-menu">',
+        '    <li class="dropdown-submenu">',
+        '      <button class="dropdown-item" type="button">More options</button>',
+        '      <ul class="dropdown-menu">',
+        '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+        '      </ul>',
+        '    </li>',
+        '  </ul>',
+        '</div>'
+      ].join('')
+
+      const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+      const dropdown = new Dropdown(btnDropdown)
+
+      // Triangle with vertices at (0,0), (10,0), (5,10)
+      const v1 = { x: 0, y: 0 }
+      const v2 = { x: 10, y: 0 }
+      const v3 = { x: 5, y: 10 }
+
+      // Point inside triangle
+      const inside = { x: 5, y: 5 }
+      expect(dropdown._pointInTriangle(inside, v1, v2, v3)).toBeTrue()
+
+      // Point outside triangle
+      const outside = { x: 20, y: 20 }
+      expect(dropdown._pointInTriangle(outside, v1, v2, v3)).toBeFalse()
+
+      // Point on edge should be inside
+      const onEdge = { x: 5, y: 0 }
+      expect(dropdown._pointInTriangle(onEdge, v1, v2, v3)).toBeTrue()
+    })
+
+    it('should track mouse position for safe triangle', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown)
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Call track method directly
+          dropdown._trackMousePosition({ clientX: 100, clientY: 200 })
+
+          // Should have tracked position in hover intent data
+          expect(dropdown._hoverIntentData).toBeDefined()
+          expect(dropdown._hoverIntentData.x).toEqual(100)
+          expect(dropdown._hoverIntentData.y).toEqual(200)
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should handle hover trigger opening submenu via internal method', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-submenu-trigger="hover">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown)
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        expect(dropdown._config.submenuTrigger).toEqual('hover')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Use internal handler directly with mock event
+          const mockEvent = { target: submenuTrigger }
+          dropdown._onSubmenuTriggerEnter(mockEvent)
+
+          // Submenu should open
+          expect(submenu.classList.contains('show')).toBeTrue()
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should handle submenu mouseleave with close delay', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown" data-bs-submenu-trigger="hover" data-bs-submenu-delay="50">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown)
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu
+          dropdown._openSubmenu(submenuTrigger, submenu, submenuWrapper)
+          expect(submenu.classList.contains('show')).toBeTrue()
+
+          // Simulate mouseleave from submenu wrapper
+          const mouseleave = new MouseEvent('mouseleave', { bubbles: true })
+          Object.defineProperty(mouseleave, 'target', { value: submenuWrapper })
+          dropdown._onSubmenuLeave(mouseleave)
+
+          // Should schedule close
+          expect(dropdown._submenuCloseTimeouts.has(submenu)).toBeTrue()
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should not schedule close if submenu is not open', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown)
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Try mouseleave without opening submenu first
+          const mouseleave = new MouseEvent('mouseleave', { bubbles: true })
+          Object.defineProperty(mouseleave, 'target', { value: submenuWrapper })
+          dropdown._onSubmenuLeave(mouseleave)
+
+          // Should not schedule close since submenu wasn't open
+          expect(dropdown._submenuCloseTimeouts.size).toEqual(0)
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should not open submenu if trigger element not found', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown)
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Mock event with target that's not a submenu trigger
+          const mockEvent = { target: btnDropdown }
+          dropdown._onSubmenuTriggerEnter(mockEvent)
+
+          // No submenus should be open
+          expect(dropdown._openSubmenus.size).toEqual(0)
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should not close submenu if already closed', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown)
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Try to close submenu that was never opened
+          dropdown._closeSubmenu(submenu, submenuWrapper)
+
+          // Should not throw, openSubmenus should still be empty
+          expect(dropdown._openSubmenus.size).toEqual(0)
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should handle _isMovingTowardSubmenu with no hover data', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown)
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // No hover data tracked yet
+          dropdown._hoverIntentData = null
+
+          const mockEvent = { clientX: 100, clientY: 100 }
+          const result = dropdown._isMovingTowardSubmenu(mockEvent, submenu)
+
+          // Should return false when no hover data
+          expect(result).toBeFalse()
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should handle click on submenu trigger when submenu is already open', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown)
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu
+          dropdown._openSubmenu(submenuTrigger, submenu, submenuWrapper)
+          expect(submenu.classList.contains('show')).toBeTrue()
+
+          // Click handler should toggle (close it)
+          const mockEvent = {
+            target: submenuTrigger,
+            preventDefault() {},
+            stopPropagation() {}
+          }
+          dropdown._onSubmenuTriggerClick(mockEvent)
+
+          expect(submenu.classList.contains('show')).toBeFalse()
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should cancel pending timeout when opening submenu', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown, { submenuDelay: 200 })
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu
+          dropdown._openSubmenu(submenuTrigger, submenu, submenuWrapper)
+
+          // Schedule close
+          dropdown._scheduleSubmenuClose(submenu, submenuWrapper)
+          expect(dropdown._submenuCloseTimeouts.has(submenu)).toBeTrue()
+
+          // Re-enter submenu trigger should cancel timeout
+          const mockEvent = { target: submenuTrigger }
+          dropdown._onSubmenuTriggerEnter(mockEvent)
+
+          // Timeout should be cancelled
+          expect(dropdown._submenuCloseTimeouts.has(submenu)).toBeFalse()
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should handle _onSubmenuTriggerClick with non-matching target', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li><a class="dropdown-item" href="#">Regular item</a></li>',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown)
+        const regularItem = fixtureEl.querySelector('.dropdown-menu > li > a')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Click on regular item (not submenu trigger)
+          const mockEvent = {
+            target: regularItem,
+            preventDefault() {},
+            stopPropagation() {}
+          }
+          dropdown._onSubmenuTriggerClick(mockEvent)
+
+          // No submenus should be affected
+          expect(dropdown._openSubmenus.size).toEqual(0)
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should handle _onSubmenuLeave when not moving toward submenu', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu" style="position: absolute; left: 200px; top: 0; width: 100px; height: 100px;">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown, { submenuDelay: 50 })
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu
+          dropdown._openSubmenu(submenuTrigger, submenu, submenuWrapper)
+
+          // Track mouse position far from submenu
+          dropdown._trackMousePosition({ clientX: 0, clientY: 0 })
+
+          // Simulate mouseleave moving away from submenu
+          const mockEvent = {
+            target: submenuWrapper,
+            clientX: -100,
+            clientY: -100
+          }
+          dropdown._onSubmenuLeave(mockEvent)
+
+          // Should schedule close since not moving toward submenu
+          expect(dropdown._submenuCloseTimeouts.has(submenu)).toBeTrue()
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should cancel timeout when calling cancelSubmenuCloseTimeout', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu" id="submenu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown, { submenuDelay: 200 })
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = fixtureEl.querySelector('#submenu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu
+          dropdown._openSubmenu(submenuTrigger, submenu, submenuWrapper)
+
+          // Schedule close
+          dropdown._scheduleSubmenuClose(submenu, submenuWrapper)
+          expect(dropdown._submenuCloseTimeouts.has(submenu)).toBeTrue()
+
+          // Cancel the timeout directly
+          dropdown._cancelSubmenuCloseTimeout(submenu)
+
+          // Timeout should be cancelled
+          expect(dropdown._submenuCloseTimeouts.has(submenu)).toBeFalse()
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should skip closing submenu if already not in openSubmenus', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown)
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Calling close on never-opened submenu should not throw
+          expect(() => {
+            dropdown._closeSubmenu(submenu, submenuWrapper)
+          }).not.toThrow()
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should handle _isMovingTowardSubmenu when cursor is in safe triangle', () => {
+      return new Promise(resolve => {
+        fixtureEl.innerHTML = [
+          '<div class="dropdown" style="position: relative;">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu" style="position: absolute; display: block;">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu" style="position: absolute; left: 100px; top: 0; width: 100px; height: 100px; display: block;">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown)
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu
+          dropdown._openSubmenu(submenuTrigger, submenu, submenuWrapper)
+
+          // Track a mouse position
+          dropdown._trackMousePosition({ clientX: 50, clientY: 50 })
+
+          // Call isMovingTowardSubmenu
+          const mockEvent = { clientX: 75, clientY: 50 }
+          const result = dropdown._isMovingTowardSubmenu(mockEvent, submenu)
+
+          // Result depends on geometry, just verify it returns a boolean
+          expect(typeof result).toBe('boolean')
+
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+
+    it('should handle RTL submenu placement', () => {
+      return new Promise(resolve => {
+        // Set RTL
+        document.documentElement.dir = 'rtl'
+
+        fixtureEl.innerHTML = [
+          '<div class="dropdown">',
+          '  <button class="btn dropdown-toggle" data-bs-toggle="dropdown">Dropdown</button>',
+          '  <ul class="dropdown-menu">',
+          '    <li class="dropdown-submenu">',
+          '      <button class="dropdown-item" type="button">More options</button>',
+          '      <ul class="dropdown-menu">',
+          '        <li><a class="dropdown-item" href="#">Sub-action</a></li>',
+          '      </ul>',
+          '    </li>',
+          '  </ul>',
+          '</div>'
+        ].join('')
+
+        const btnDropdown = fixtureEl.querySelector('[data-bs-toggle="dropdown"]')
+        const dropdown = new Dropdown(btnDropdown)
+        const submenuTrigger = fixtureEl.querySelector('.dropdown-submenu > .dropdown-item')
+        const submenuWrapper = fixtureEl.querySelector('.dropdown-submenu')
+        const submenu = submenuWrapper.querySelector('.dropdown-menu')
+
+        btnDropdown.addEventListener('shown.bs.dropdown', () => {
+          // Open submenu in RTL mode
+          dropdown._openSubmenu(submenuTrigger, submenu, submenuWrapper)
+          expect(submenu.classList.contains('show')).toBeTrue()
+
+          // Reset RTL
+          document.documentElement.dir = 'ltr'
+          resolve()
+        })
+
+        dropdown.show()
+      })
+    })
+  })
 })
diff --git a/js/tests/visual/dropdown-submenu.html b/js/tests/visual/dropdown-submenu.html
new file mode 100644 (file)
index 0000000..351aa34
--- /dev/null
@@ -0,0 +1,482 @@
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <link href="../../../dist/css/bootstrap.min.css" rel="stylesheet">
+    <title>Dropdown Submenus</title>
+    <style>
+      .test-section {
+        padding: 2rem 0;
+        border-bottom: 1px solid var(--border-color);
+      }
+      .test-section:last-child {
+        border-bottom: 0;
+      }
+      .demo-box {
+        min-height: 300px;
+        display: flex;
+        align-items: flex-start;
+        gap: 1rem;
+        flex-wrap: wrap;
+      }
+      .demo-box-center {
+        align-items: center;
+        justify-content: center;
+      }
+      .feature-list {
+        font-size: 0.875rem;
+        color: var(--fg-2);
+      }
+      .feature-list li {
+        margin-bottom: 0.25rem;
+      }
+      .keyboard-hint {
+        display: inline-block;
+        padding: 0.125rem 0.375rem;
+        font-size: 0.75rem;
+        font-family: ui-monospace, monospace;
+        background: var(--bg-2);
+        border-radius: var(--border-radius);
+        border: 1px solid var(--border-color);
+      }
+    </style>
+  </head>
+  <body>
+    <div class="container py-4">
+      <h1 class="mb-4">Dropdown Submenus <small class="text-body-secondary">Bootstrap Visual Test</small></h1>
+
+      <div class="alert alert-info">
+        <strong>Keyboard Navigation:</strong>
+        <span class="keyboard-hint">↓</span> <span class="keyboard-hint">↑</span> navigate items,
+        <span class="keyboard-hint">→</span> enter submenu,
+        <span class="keyboard-hint">←</span> exit submenu,
+        <span class="keyboard-hint">Enter</span> or <span class="keyboard-hint">Space</span> activate,
+        <span class="keyboard-hint">Esc</span> close,
+        <span class="keyboard-hint">Home</span> <span class="keyboard-hint">End</span> jump to first/last
+      </div>
+
+      <!-- Basic Submenu -->
+      <section class="test-section">
+        <h2>Basic Submenu</h2>
+        <p class="text-body-secondary">Single level submenu with hover and click activation.</p>
+
+        <div class="demo-box">
+          <div class="dropdown">
+            <button class="btn btn-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+              Dropdown with Submenu
+            </button>
+            <ul class="dropdown-menu">
+              <li><a class="dropdown-item" href="#">Action</a></li>
+              <li><a class="dropdown-item" href="#">Another action</a></li>
+              <li class="dropdown-divider"></li>
+              <li class="dropdown-submenu">
+                <button class="dropdown-item" type="button">
+                  More options
+                </button>
+                <ul class="dropdown-menu">
+                  <li><a class="dropdown-item" href="#">Sub-action 1</a></li>
+                  <li><a class="dropdown-item" href="#">Sub-action 2</a></li>
+                  <li><a class="dropdown-item" href="#">Sub-action 3</a></li>
+                </ul>
+              </li>
+              <li><a class="dropdown-item" href="#">Something else</a></li>
+            </ul>
+          </div>
+        </div>
+      </section>
+
+      <!-- Nested Submenus -->
+      <section class="test-section">
+        <h2>Nested Submenus (Multi-level)</h2>
+        <p class="text-body-secondary">Three levels of nested submenus.</p>
+
+        <div class="demo-box">
+          <div class="dropdown">
+            <button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+              Multi-level Menu
+            </button>
+            <ul class="dropdown-menu">
+              <li><a class="dropdown-item" href="#">Level 1 - Action</a></li>
+              <li class="dropdown-submenu">
+                <button class="dropdown-item" type="button">
+                  Level 1 - Submenu
+                </button>
+                <ul class="dropdown-menu">
+                  <li><a class="dropdown-item" href="#">Level 2 - Action</a></li>
+                  <li class="dropdown-submenu">
+                    <button class="dropdown-item" type="button">
+                      Level 2 - Submenu
+                    </button>
+                    <ul class="dropdown-menu">
+                      <li><a class="dropdown-item" href="#">Level 3 - Action A</a></li>
+                      <li><a class="dropdown-item" href="#">Level 3 - Action B</a></li>
+                      <li><a class="dropdown-item" href="#">Level 3 - Action C</a></li>
+                    </ul>
+                  </li>
+                  <li><a class="dropdown-item" href="#">Level 2 - Another</a></li>
+                </ul>
+              </li>
+              <li><a class="dropdown-item" href="#">Level 1 - Another</a></li>
+            </ul>
+          </div>
+        </div>
+      </section>
+
+      <!-- Multiple Submenus -->
+      <section class="test-section">
+        <h2>Multiple Submenus at Same Level</h2>
+        <p class="text-body-secondary">Multiple submenu triggers in the same menu - opening one closes the other.</p>
+
+        <div class="demo-box">
+          <div class="dropdown">
+            <button class="btn btn-info dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+              Multiple Submenus
+            </button>
+            <ul class="dropdown-menu">
+              <li class="dropdown-submenu">
+                <button class="dropdown-item" type="button">
+                  File operations
+                </button>
+                <ul class="dropdown-menu">
+                  <li><a class="dropdown-item" href="#">New</a></li>
+                  <li><a class="dropdown-item" href="#">Open</a></li>
+                  <li><a class="dropdown-item" href="#">Save</a></li>
+                  <li><a class="dropdown-item" href="#">Save As...</a></li>
+                </ul>
+              </li>
+              <li class="dropdown-submenu">
+                <button class="dropdown-item" type="button">
+                  Edit operations
+                </button>
+                <ul class="dropdown-menu">
+                  <li><a class="dropdown-item" href="#">Cut</a></li>
+                  <li><a class="dropdown-item" href="#">Copy</a></li>
+                  <li><a class="dropdown-item" href="#">Paste</a></li>
+                </ul>
+              </li>
+              <li class="dropdown-submenu">
+                <button class="dropdown-item" type="button">
+                  View options
+                </button>
+                <ul class="dropdown-menu">
+                  <li><a class="dropdown-item" href="#">Zoom In</a></li>
+                  <li><a class="dropdown-item" href="#">Zoom Out</a></li>
+                  <li><a class="dropdown-item" href="#">Fit to Window</a></li>
+                </ul>
+              </li>
+              <li class="dropdown-divider"></li>
+              <li><a class="dropdown-item" href="#">Preferences</a></li>
+            </ul>
+          </div>
+        </div>
+      </section>
+
+      <!-- Viewport Flipping Test -->
+      <section class="test-section">
+        <h2>Viewport Detection (Flipping)</h2>
+        <p class="text-body-secondary">Submenus flip to the opposite side when there's not enough space. Try the one on the right.</p>
+
+        <div class="demo-box" style="justify-content: space-between;">
+          <div class="dropdown">
+            <button class="btn btn-success dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+              Left Side (opens right)
+            </button>
+            <ul class="dropdown-menu">
+              <li><a class="dropdown-item" href="#">Action</a></li>
+              <li class="dropdown-submenu">
+                <button class="dropdown-item" type="button">
+                  Submenu
+                </button>
+                <ul class="dropdown-menu">
+                  <li><a class="dropdown-item" href="#">Sub-action 1</a></li>
+                  <li><a class="dropdown-item" href="#">Sub-action 2</a></li>
+                </ul>
+              </li>
+            </ul>
+          </div>
+
+          <div class="dropdown">
+            <button class="btn btn-success dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+              Right Side (flips left)
+            </button>
+            <ul class="dropdown-menu">
+              <li><a class="dropdown-item" href="#">Action</a></li>
+              <li class="dropdown-submenu">
+                <button class="dropdown-item" type="button">
+                  Submenu (should flip)
+                </button>
+                <ul class="dropdown-menu">
+                  <li><a class="dropdown-item" href="#">Sub-action 1</a></li>
+                  <li><a class="dropdown-item" href="#">Sub-action 2</a></li>
+                  <li><a class="dropdown-item" href="#">Sub-action 3</a></li>
+                </ul>
+              </li>
+            </ul>
+          </div>
+        </div>
+      </section>
+
+      <!-- Navbar Integration -->
+      <section class="test-section">
+        <h2>Navbar Integration</h2>
+        <p class="text-body-secondary">Submenus work within navbar dropdowns.</p>
+
+        <nav class="navbar navbar-expand-lg bg-body-tertiary rounded">
+          <div class="container-fluid">
+            <a class="navbar-brand" href="#">Navbar</a>
+            <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSubmenu" aria-controls="navbarSubmenu" aria-expanded="false" aria-label="Toggle navigation">
+              <span class="navbar-toggler-icon"></span>
+            </button>
+            <div class="collapse navbar-collapse" id="navbarSubmenu">
+              <ul class="navbar-nav me-auto mb-2 mb-lg-0">
+                <li class="nav-item">
+                  <a class="nav-link active" aria-current="page" href="#">Home</a>
+                </li>
+                <li class="nav-item dropdown">
+                  <a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
+                    Products
+                  </a>
+                  <ul class="dropdown-menu">
+                    <li class="dropdown-submenu">
+                      <button class="dropdown-item" type="button">
+                        Electronics
+                      </button>
+                      <ul class="dropdown-menu">
+                        <li><a class="dropdown-item" href="#">Phones</a></li>
+                        <li><a class="dropdown-item" href="#">Laptops</a></li>
+                        <li><a class="dropdown-item" href="#">Tablets</a></li>
+                      </ul>
+                    </li>
+                    <li class="dropdown-submenu">
+                      <button class="dropdown-item" type="button">
+                        Clothing
+                      </button>
+                      <ul class="dropdown-menu">
+                        <li><a class="dropdown-item" href="#">Men's</a></li>
+                        <li><a class="dropdown-item" href="#">Women's</a></li>
+                        <li><a class="dropdown-item" href="#">Kids</a></li>
+                      </ul>
+                    </li>
+                    <li class="dropdown-divider"></li>
+                    <li><a class="dropdown-item" href="#">All Products</a></li>
+                  </ul>
+                </li>
+                <li class="nav-item">
+                  <a class="nav-link" href="#">About</a>
+                </li>
+              </ul>
+            </div>
+          </div>
+        </nav>
+      </section>
+
+      <!-- Dropup with Submenus -->
+      <section class="test-section">
+        <h2>Dropup with Submenus</h2>
+        <p class="text-body-secondary">Submenus work with dropup direction.</p>
+
+        <div class="demo-box demo-box-center" style="min-height: 200px;">
+          <div class="btn-group dropup">
+            <button type="button" class="btn btn-warning dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
+              Dropup Menu
+            </button>
+            <ul class="dropdown-menu">
+              <li><a class="dropdown-item" href="#">Action</a></li>
+              <li class="dropdown-submenu">
+                <button class="dropdown-item" type="button">
+                  More options
+                </button>
+                <ul class="dropdown-menu">
+                  <li><a class="dropdown-item" href="#">Sub-action 1</a></li>
+                  <li><a class="dropdown-item" href="#">Sub-action 2</a></li>
+                </ul>
+              </li>
+              <li><a class="dropdown-item" href="#">Something else</a></li>
+            </ul>
+          </div>
+        </div>
+      </section>
+
+      <!-- With Icons -->
+      <section class="test-section">
+        <h2>With Icons</h2>
+        <p class="text-body-secondary">Submenus with icons in menu items.</p>
+
+        <div class="demo-box">
+          <div class="dropdown">
+            <button class="btn btn-dark dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+              Menu with Icons
+            </button>
+            <ul class="dropdown-menu">
+              <li>
+                <a class="dropdown-item" href="#">
+                  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
+                    <path d="M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71V3.5z"/>
+                    <path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm7-8A7 7 0 1 1 1 8a7 7 0 0 1 14 0z"/>
+                  </svg>
+                  Recent
+                </a>
+              </li>
+              <li class="dropdown-submenu">
+                <button class="dropdown-item" type="button">
+                  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
+                    <path d="M1 3.5A1.5 1.5 0 0 1 2.5 2h2.764c.958 0 1.76.56 2.311 1.184C7.985 3.648 8.48 4 9 4h4.5A1.5 1.5 0 0 1 15 5.5v.64c.57.265.94.876.856 1.546l-.64 5.124A2.5 2.5 0 0 1 12.733 15H3.266a2.5 2.5 0 0 1-2.481-2.19l-.64-5.124A1.5 1.5 0 0 1 1 6.14V3.5zM2 6h12v-.5a.5.5 0 0 0-.5-.5H9c-.964 0-1.71-.629-2.174-1.154C6.374 3.334 5.82 3 5.264 3H2.5a.5.5 0 0 0-.5.5V6zm-.367 1a.5.5 0 0 0-.496.562l.64 5.124A1.5 1.5 0 0 0 3.266 14h9.468a1.5 1.5 0 0 0 1.489-1.314l.64-5.124A.5.5 0 0 0 14.367 7H1.633z"/>
+                  </svg>
+                  Folders
+                </button>
+                <ul class="dropdown-menu">
+                  <li><a class="dropdown-item" href="#">Documents</a></li>
+                  <li><a class="dropdown-item" href="#">Downloads</a></li>
+                  <li><a class="dropdown-item" href="#">Pictures</a></li>
+                </ul>
+              </li>
+              <li class="dropdown-divider"></li>
+              <li>
+                <a class="dropdown-item" href="#">
+                  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
+                    <path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/>
+                    <path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319zm-2.633.283c.246-.835 1.428-.835 1.674 0l.094.319a1.873 1.873 0 0 0 2.693 1.115l.291-.16c.764-.415 1.6.42 1.184 1.185l-.159.292a1.873 1.873 0 0 0 1.116 2.692l.318.094c.835.246.835 1.428 0 1.674l-.319.094a1.873 1.873 0 0 0-1.115 2.693l.16.291c.415.764-.42 1.6-1.185 1.184l-.291-.159a1.873 1.873 0 0 0-2.693 1.116l-.094.318c-.246.835-1.428.835-1.674 0l-.094-.319a1.873 1.873 0 0 0-2.692-1.115l-.292.16c-.764.415-1.6-.42-1.184-1.185l.159-.291A1.873 1.873 0 0 0 1.945 8.93l-.319-.094c-.835-.246-.835-1.428 0-1.674l.319-.094A1.873 1.873 0 0 0 3.06 4.377l-.16-.292c-.415-.764.42-1.6 1.185-1.184l.292.159a1.873 1.873 0 0 0 2.692-1.115l.094-.319z"/>
+                  </svg>
+                  Settings
+                </a>
+              </li>
+            </ul>
+          </div>
+        </div>
+      </section>
+
+      <!-- Mobile Test -->
+      <section class="test-section">
+        <h2>Mobile Mode</h2>
+        <p class="text-body-secondary">
+          Resize your browser to &lt;768px width to see slide-over behavior.
+          On mobile, submenus slide in from the side with a back button.
+        </p>
+
+        <div class="demo-box">
+          <div class="dropdown">
+            <button class="btn btn-danger dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+              Test on Mobile
+            </button>
+            <ul class="dropdown-menu">
+              <li><a class="dropdown-item" href="#">Action</a></li>
+              <li class="dropdown-submenu">
+                <button class="dropdown-item" type="button">
+                  Navigate to submenu
+                </button>
+                <ul class="dropdown-menu">
+                  <li><a class="dropdown-item" href="#">Sub-action 1</a></li>
+                  <li><a class="dropdown-item" href="#">Sub-action 2</a></li>
+                  <li class="dropdown-submenu">
+                    <button class="dropdown-item" type="button">
+                      Even deeper
+                    </button>
+                    <ul class="dropdown-menu">
+                      <li><a class="dropdown-item" href="#">Deep action 1</a></li>
+                      <li><a class="dropdown-item" href="#">Deep action 2</a></li>
+                    </ul>
+                  </li>
+                </ul>
+              </li>
+              <li><a class="dropdown-item" href="#">Something else</a></li>
+            </ul>
+          </div>
+        </div>
+      </section>
+
+      <!-- Disabled Items -->
+      <section class="test-section">
+        <h2>With Disabled Items</h2>
+        <p class="text-body-secondary">Keyboard navigation skips disabled items.</p>
+
+        <div class="demo-box">
+          <div class="dropdown">
+            <button class="btn btn-outline-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+              Menu with Disabled
+            </button>
+            <ul class="dropdown-menu">
+              <li><a class="dropdown-item" href="#">Enabled action</a></li>
+              <li><a class="dropdown-item disabled" href="#">Disabled action</a></li>
+              <li class="dropdown-submenu">
+                <button class="dropdown-item" type="button">
+                  Submenu
+                </button>
+                <ul class="dropdown-menu">
+                  <li><a class="dropdown-item" href="#">Enabled</a></li>
+                  <li><a class="dropdown-item disabled" href="#">Disabled</a></li>
+                  <li><a class="dropdown-item" href="#">Enabled</a></li>
+                </ul>
+              </li>
+            </ul>
+          </div>
+        </div>
+      </section>
+
+      <!-- Feature Summary -->
+      <section class="test-section">
+        <h2>Feature Summary</h2>
+        <div class="row">
+          <div class="col-md-6">
+            <h5>Mouse Interaction</h5>
+            <ul class="feature-list">
+              <li>✅ Hover to open submenus</li>
+              <li>✅ Click to toggle submenus</li>
+              <li>✅ Safe triangle / hover intent (diagonal movement)</li>
+              <li>✅ Configurable close delay</li>
+              <li>✅ Sibling submenus auto-close</li>
+            </ul>
+          </div>
+          <div class="col-md-6">
+            <h5>Keyboard Navigation</h5>
+            <ul class="feature-list">
+              <li>✅ Arrow Up/Down - navigate items</li>
+              <li>✅ Arrow Right - enter submenu (Left in RTL)</li>
+              <li>✅ Arrow Left - exit submenu (Right in RTL)</li>
+              <li>✅ Enter/Space - activate item or open submenu</li>
+              <li>✅ Escape - close current submenu or dropdown</li>
+              <li>✅ Home/End - jump to first/last item</li>
+            </ul>
+          </div>
+          <div class="col-md-6">
+            <h5>Viewport Detection</h5>
+            <ul class="feature-list">
+              <li>✅ Default: opens to inline-end (right in LTR)</li>
+              <li>✅ Flips to inline-start when not enough space</li>
+              <li>✅ Shift to stay within viewport</li>
+              <li>✅ Auto-update on scroll/resize</li>
+            </ul>
+          </div>
+          <div class="col-md-6">
+            <h5>Mobile Mode</h5>
+            <ul class="feature-list">
+              <li>✅ Slide-over animation</li>
+              <li>✅ Back button navigation</li>
+              <li>✅ Full-screen submenu panels</li>
+              <li>✅ Configurable breakpoint</li>
+            </ul>
+          </div>
+          <div class="col-md-6">
+            <h5>Accessibility</h5>
+            <ul class="feature-list">
+              <li>✅ aria-haspopup on submenu triggers</li>
+              <li>✅ aria-expanded state management</li>
+              <li>✅ Focus management</li>
+              <li>✅ Focus returns to trigger on close</li>
+            </ul>
+          </div>
+          <div class="col-md-6">
+            <h5>Configuration Options</h5>
+            <ul class="feature-list">
+              <li>✅ submenuTrigger: 'click' | 'hover' | 'both'</li>
+              <li>✅ submenuDelay: close delay in ms</li>
+              <li>✅ mobileBreakpoint: px for mobile mode</li>
+            </ul>
+          </div>
+        </div>
+      </section>
+    </div>
+
+    <script src="../../../dist/js/bootstrap.bundle.js"></script>
+  </body>
+</html>
index e99ccf9bf779b07e0e325d7871a79e88e4aff4b7..cf42ec9f0cd8f2ad458db25127c7b94e8b4d1d1e 100644 (file)
@@ -5,6 +5,7 @@
 @use "mixins/border-radius" as *;
 @use "mixins/box-shadow" as *;
 @use "mixins/gradients" as *;
+@use "mixins/transition" as *;
 
 // scss-docs-start dropdown-variables
 $dropdown-gap:                      $spacer * .125 !default;
@@ -218,4 +219,60 @@ $dropdown-dark-header-color:        var(--gray-500) !default;
     --dropdown-header-color: #{$dropdown-dark-header-color};
     // scss-docs-end dropdown-dark-css-vars
   }
+
+  // scss-docs-start dropdown-submenu
+  // Submenus
+  //
+  // Nested dropdown menus with hover/click activation and keyboard support.
+
+  .dropdown-submenu {
+    position: relative;
+
+    // Submenu trigger styling
+    > .dropdown-item {
+      display: flex;
+      align-items: center;
+      justify-content: space-between;
+    }
+
+    // Submenu caret indicator
+    > .dropdown-item::after {
+      display: inline-block;
+      flex-shrink: 0;
+      width: .375em;
+      height: .375em;
+      margin-inline-start: auto;
+      content: "";
+      border-color: currentcolor;
+      border-style: solid;
+      border-width: 0 .125em .125em 0;
+      transform: rotate(-45deg);
+
+      // RTL: flip the chevron direction
+      [dir="rtl"] & {
+        transform: rotate(135deg);
+      }
+    }
+
+    // Submenu positioning (set by JS via Floating UI)
+    > .dropdown-menu {
+      top: 0;
+      // Offset to align with parent item
+      margin-top: calc(-1 * var(--dropdown-padding-y));
+    }
+
+    // Hover state for submenu trigger
+    &:hover > .dropdown-item,
+    &:focus-within > .dropdown-item {
+      color: var(--dropdown-link-hover-color);
+      background-color: var(--dropdown-link-hover-bg);
+    }
+
+    // Active/open state
+    &.show > .dropdown-item {
+      color: var(--dropdown-link-hover-color);
+      background-color: var(--dropdown-link-hover-bg);
+    }
+  }
+  // scss-docs-end dropdown-submenu
 }
index b6e670e55f7d00cb617cedf3b18a9998cf14efd4..2cfd707919f8bc0e36168aea5f45b7f5e50ad1fe 100644 (file)
@@ -392,7 +392,7 @@ $kbd-padding-y:                     .1875rem !default;
 $kbd-padding-x:                     .375rem !default;
 $kbd-font-size:                     $code-font-size !default;
 $kbd-color:                         var(--bg-body) !default;
-$kbd-bg:                            var(--color-body) !default;
+$kbd-bg:                            var(--fg-body) !default;
 
 $pre-color:                         null !default;
 
diff --git a/site/src/components/shortcodes/DropdownPlacementPlayground.astro b/site/src/components/shortcodes/DropdownPlacementPlayground.astro
new file mode 100644 (file)
index 0000000..5528d7c
--- /dev/null
@@ -0,0 +1,217 @@
+---
+import Example from '@components/shortcodes/Example.astro'
+
+// Physical placements
+const physicalPlacements = [
+  { value: 'top-start', label: 'top-start' },
+  { value: 'top', label: 'top' },
+  { value: 'top-end', label: 'top-end' },
+  { value: 'right-start', label: 'right-start' },
+  { value: 'right', label: 'right' },
+  { value: 'right-end', label: 'right-end' },
+  { value: 'bottom-start', label: 'bottom-start' },
+  { value: 'bottom', label: 'bottom' },
+  { value: 'bottom-end', label: 'bottom-end' },
+  { value: 'left-start', label: 'left-start' },
+  { value: 'left', label: 'left' },
+  { value: 'left-end', label: 'left-end' }
+]
+
+// Logical placements
+const logicalPlacements = [
+  { value: 'start-start', label: 'start-start' },
+  { value: 'start', label: 'start' },
+  { value: 'start-end', label: 'start-end' },
+  { value: 'end-start', label: 'end-start' },
+  { value: 'end', label: 'end' },
+  { value: 'end-end', label: 'end-end' }
+]
+---
+
+<div class="bg-1 p-3 rounded-3 mb-3">
+  <div class="d-flex flex-wrap gap-3 align-items-end">
+    <div class="vstack gap-1">
+      <label class="form-label fw-semibold mb-0">Placement type</label>
+      <div class="btn-group" role="group" aria-label="Placement type">
+        <label class="btn-check btn-outline theme-secondary">
+          <input
+            type="radio"
+            name="placement-type"
+            value="physical"
+            checked
+            data-placement-type="physical"
+          />
+          Physical
+        </label>
+        <label class="btn-check btn-outline theme-secondary">
+          <input
+            type="radio"
+            name="placement-type"
+            value="logical"
+            data-placement-type="logical"
+          />
+          Logical
+        </label>
+      </div>
+    </div>
+
+    <div class="vstack gap-1">
+      <label class="form-label fw-semibold mb-0">Placement</label>
+      <div class="dropdown">
+        <button
+          type="button"
+          class="btn btn-outline theme-secondary dropdown-toggle w-100 justify-content-between"
+          id="placement-dropdown"
+          data-bs-toggle="dropdown"
+          aria-expanded="false"
+          data-placement="bottom-start"
+          style="min-width: 160px;"
+        >
+          bottom-start
+        </button>
+        <ul class="dropdown-menu" aria-labelledby="placement-dropdown">
+          {physicalPlacements.map((p) => (
+            <li data-placement-group="physical">
+              <a
+                class:list={['dropdown-item', { 'active': p.value === 'bottom-start' }]}
+                href="#"
+                data-placement={p.value}
+              >
+                {p.label}
+              </a>
+            </li>
+          ))}
+          {logicalPlacements.map((p) => (
+            <li data-placement-group="logical" class="d-none">
+              <a
+                class="dropdown-item"
+                href="#"
+                data-placement={p.value}
+              >
+                {p.label}
+              </a>
+            </li>
+          ))}
+        </ul>
+      </div>
+    </div>
+  </div>
+</div>
+
+<Example
+  class="d-flex justify-content-center align-items-center"
+  style="min-height: 200px;"
+  code={`<div class="dropdown">
+  <button class="btn btn-solid theme-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-placement="bottom-start" aria-expanded="false">
+    Toggle dropdown
+  </button>
+  <ul class="dropdown-menu">
+    <li><a class="dropdown-item" href="#">Action</a></li>
+    <li><a class="dropdown-item" href="#">Another action</a></li>
+    <li><a class="dropdown-item" href="#">Something else here</a></li>
+  </ul>
+</div>`}
+  id="placement-preview"
+/>
+
+<script>
+  const placementDropdownButton = document.querySelector('#placement-dropdown') as HTMLButtonElement
+  const placementDropdownItems = document.querySelectorAll('#placement-dropdown + .dropdown-menu .dropdown-item')
+  const placementTypeInputs = document.querySelectorAll('input[name="placement-type"]')
+  const previewDropdown = document.querySelector('#placement-preview .dropdown')
+  const previewToggle = previewDropdown?.querySelector('[data-bs-toggle="dropdown"]') as HTMLButtonElement
+  const codeSnippet = document.querySelector('#placement-preview')?.closest('.bd-example-snippet')?.querySelector('.highlight code') as HTMLElement
+
+  function updateDropdownItems() {
+    const selectedType = (document.querySelector('input[name="placement-type"]:checked') as HTMLInputElement)?.value || 'physical'
+
+    // Show/hide placement options based on type
+    document.querySelectorAll('[data-placement-group="physical"]').forEach(el => {
+      el.classList.toggle('d-none', selectedType !== 'physical')
+    })
+    document.querySelectorAll('[data-placement-group="logical"]').forEach(el => {
+      el.classList.toggle('d-none', selectedType !== 'logical')
+    })
+
+    // Select first visible option if current selection is hidden
+    const currentPlacement = placementDropdownButton?.dataset.placement || ''
+    const isPhysical = !currentPlacement.startsWith('start') && !currentPlacement.startsWith('end')
+    const needsReset = (selectedType === 'physical' && !isPhysical) || (selectedType === 'logical' && isPhysical)
+
+    if (needsReset) {
+      const defaultPlacement = selectedType === 'physical' ? 'bottom-start' : 'start-start'
+      updatePlacement(defaultPlacement)
+    }
+  }
+
+  function updatePlacement(placement: string) {
+    if (!placementDropdownButton || !previewToggle) return
+
+    // Update the placement selector button
+    placementDropdownButton.textContent = placement
+    placementDropdownButton.dataset.placement = placement
+
+    // Update active state in dropdown
+    placementDropdownItems.forEach(item => {
+      const itemPlacement = (item as HTMLElement).dataset.placement
+      item.classList.toggle('active', itemPlacement === placement)
+    })
+
+    // Update the preview dropdown
+    previewToggle.setAttribute('data-bs-placement', placement)
+
+    // Dispose and recreate the dropdown instance to pick up new placement
+    const instance = bootstrap.Dropdown.getInstance(previewToggle)
+    if (instance) {
+      instance.dispose()
+    }
+    bootstrap.Dropdown.getOrCreateInstance(previewToggle)
+
+    // Update code snippet
+    updateCodeSnippet(placement)
+  }
+
+  function updateCodeSnippet(placement: string) {
+    if (!codeSnippet) return
+
+    const htmlCode = `<div class="dropdown">
+  <button class="btn btn-solid theme-primary dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-placement="${placement}" aria-expanded="false">
+    Toggle dropdown
+  </button>
+  <ul class="dropdown-menu">
+    <li><a class="dropdown-item" href="#">Action</a></li>
+    <li><a class="dropdown-item" href="#">Another action</a></li>
+    <li><a class="dropdown-item" href="#">Something else here</a></li>
+  </ul>
+</div>`
+
+    codeSnippet.className = 'language-html'
+    codeSnippet.textContent = htmlCode
+
+    if (typeof window !== 'undefined' && (window as any).Prism) {
+      (window as any).Prism.highlightElement(codeSnippet)
+    }
+  }
+
+  // Initialize placement dropdown
+  if (placementDropdownButton) {
+    const placementDropdown = bootstrap.Dropdown.getOrCreateInstance(placementDropdownButton)
+
+    placementDropdownItems.forEach(item => {
+      item.addEventListener('click', (e) => {
+        e.preventDefault()
+        const placement = (item as HTMLElement).dataset.placement || 'bottom-start'
+        updatePlacement(placement)
+        placementDropdown.hide()
+      })
+    })
+  }
+
+  // Handle placement type change
+  placementTypeInputs.forEach(input => {
+    input.addEventListener('change', updateDropdownItems)
+  })
+
+  // Initial setup
+  updateDropdownItems()
+</script>
index e67f5bdf79e450a5732afa5a18d1024cca4b45cb..8cf1a20c0725a4089e75eb5db7669ae348ff2620 100644 (file)
@@ -23,15 +23,7 @@ Toggle dropdown menus with buttons whenever possible. Here's an example using a
 
 Dropdowns are toggleable, contextual overlays for displaying lists of links and more. They’re made interactive with the included Bootstrap dropdown JavaScript plugin. They’re toggled by clicking, not by hovering; this is [an intentional design decision](https://markdotto.com/blog/bootstrap-explained-dropdowns/).
 
-Dropdowns are built on a third party library, [Floating UI](https://floating-ui.com/), which provides dynamic positioning and viewport detection. Be sure to include [floating-ui.dom.umd.min.js]([[config:cdn.floating_ui]]) before Bootstrap’s JavaScript or use `bootstrap.bundle.min.js` / `bootstrap.bundle.js` which contains Floating UI. Popper isn’t used to position dropdowns in navbars though as dynamic positioning isn’t required.
-
-## Accessibility
-
-The [<abbr title="Web Accessibility Initiative">WAI</abbr> <abbr title="Accessible Rich Internet Applications">ARIA</abbr>](https://www.w3.org/TR/wai-aria/) standard defines an actual [`role="menu"` widget](https://www.w3.org/TR/wai-aria/#menu), but this is specific to application-like menus which trigger actions or functions. <abbr title="Accessible Rich Internet Applications">ARIA</abbr> menus can only contain menu items, checkbox menu items, radio button menu items, radio button groups, and sub-menus.
-
-Bootstrap’s dropdowns, on the other hand, are designed to be generic and applicable to a variety of situations and markup structures. For instance, it is possible to create dropdowns that contain additional inputs and form controls, such as search fields or login forms. For this reason, Bootstrap does not expect (nor automatically add) any of the `role` and `aria-` attributes required for true <abbr title="Accessible Rich Internet Applications">ARIA</abbr> menus. Authors will have to include these more specific attributes themselves.
-
-However, Bootstrap does add built-in support for most standard keyboard menu interactions, such as the ability to move through individual `.dropdown-item` elements using the cursor keys and close the menu with the <kbd>Esc</kbd> key.
+Dropdowns are built on a third party library, [Floating UI](https://floating-ui.com/), which provides dynamic positioning and viewport detection. Be sure to include [floating-ui.dom.umd.min.js]([[config:cdn.floating_ui]]) before Bootstrap's JavaScript or use `bootstrap.bundle.min.js` / `bootstrap.bundle.js` which contains Floating UI. Popper isn't used to position dropdowns in navbars though as dynamic positioning isn't required.
 
 ## Examples
 
@@ -134,145 +126,21 @@ And putting it to use in a navbar:
 
 ## Placement
 
-Use `data-bs-placement` on the toggle element to control where the dropdown menu appears. Placement options include `top`, `bottom`, `left`, and `right`, each with optional `-start` and `-end` alignment modifiers.
-
-<Callout>
-**Directions are flipped in RTL mode.** As such, `left` placements will appear on the right side.
-</Callout>
-
-### Bottom
-
-The default placement. Use `bottom`, `bottom-start`, or `bottom-end` to position the menu below the toggle.
-
-<Example class="d-flex gap-2" code={`<div class="dropdown">
-    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" data-bs-placement="bottom-start" aria-expanded="false">
-      Bottom start
-    </button>
-    <ul class="dropdown-menu">
-      <li><a class="dropdown-item" href="#">Action</a></li>
-      <li><a class="dropdown-item" href="#">Another action</a></li>
-    </ul>
-  </div>
-  <div class="dropdown">
-    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" data-bs-placement="bottom" aria-expanded="false">
-      Bottom
-    </button>
-    <ul class="dropdown-menu">
-      <li><a class="dropdown-item" href="#">Action</a></li>
-      <li><a class="dropdown-item" href="#">Another action</a></li>
-    </ul>
-  </div>
-  <div class="dropdown">
-    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" data-bs-placement="bottom-end" aria-expanded="false">
-      Bottom end
-    </button>
-    <ul class="dropdown-menu">
-      <li><a class="dropdown-item" href="#">Action</a></li>
-      <li><a class="dropdown-item" href="#">Another action</a></li>
-    </ul>
-  </div>`} />
-
-### Top
-
-Use `top`, `top-start`, or `top-end` to position the menu above the toggle.
-
-<Example class="d-flex gap-2" code={`<div class="dropdown">
-    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" data-bs-placement="top-start" aria-expanded="false">
-      Top start
-    </button>
-    <ul class="dropdown-menu">
-      <li><a class="dropdown-item" href="#">Action</a></li>
-      <li><a class="dropdown-item" href="#">Another action</a></li>
-    </ul>
-  </div>
-  <div class="dropdown">
-    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" data-bs-placement="top" aria-expanded="false">
-      Top
-    </button>
-    <ul class="dropdown-menu">
-      <li><a class="dropdown-item" href="#">Action</a></li>
-      <li><a class="dropdown-item" href="#">Another action</a></li>
-    </ul>
-  </div>
-  <div class="dropdown">
-    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" data-bs-placement="top-end" aria-expanded="false">
-      Top end
-    </button>
-    <ul class="dropdown-menu">
-      <li><a class="dropdown-item" href="#">Action</a></li>
-      <li><a class="dropdown-item" href="#">Another action</a></li>
-    </ul>
-  </div>`} />
-
-### Left
+Use `data-bs-placement` on the toggle element to control where the dropdown menu appears.
 
-Use `left`, `left-start`, or `left-end` to position the menu to the left of the toggle.
+**Physical placements:** `top`, `bottom`, `left`, `right` — fixed directions regardless of text direction.
 
-<Example class="d-flex gap-2" code={`<div class="dropdown">
-    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" data-bs-placement="left-start" aria-expanded="false">
-      Left start
-    </button>
-    <ul class="dropdown-menu">
-      <li><a class="dropdown-item" href="#">Action</a></li>
-      <li><a class="dropdown-item" href="#">Another action</a></li>
-    </ul>
-  </div>
-  <div class="dropdown">
-    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" data-bs-placement="left" aria-expanded="false">
-      Left
-    </button>
-    <ul class="dropdown-menu">
-      <li><a class="dropdown-item" href="#">Action</a></li>
-      <li><a class="dropdown-item" href="#">Another action</a></li>
-    </ul>
-  </div>
-  <div class="dropdown">
-    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" data-bs-placement="left-end" aria-expanded="false">
-      Left end
-    </button>
-    <ul class="dropdown-menu">
-      <li><a class="dropdown-item" href="#">Action</a></li>
-      <li><a class="dropdown-item" href="#">Another action</a></li>
-    </ul>
-  </div>`} />
+**Logical placements:** `start`, `end` — automatically flip based on RTL. In LTR mode, `start` becomes `left` and `end` becomes `right`. In RTL mode, they swap.
 
-### Right
+All placements support `-start` and `-end` alignment modifiers (e.g., `bottom-start`, `end-end`).
 
-Use `right`, `right-start`, or `right-end` to position the menu to the right of the toggle.
-
-<Example class="d-flex gap-2" code={`<div class="dropdown">
-    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" data-bs-placement="right-start" aria-expanded="false">
-      Right start
-    </button>
-    <ul class="dropdown-menu">
-      <li><a class="dropdown-item" href="#">Action</a></li>
-      <li><a class="dropdown-item" href="#">Another action</a></li>
-    </ul>
-  </div>
-  <div class="dropdown">
-    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" data-bs-placement="right" aria-expanded="false">
-      Right
-    </button>
-    <ul class="dropdown-menu">
-      <li><a class="dropdown-item" href="#">Action</a></li>
-      <li><a class="dropdown-item" href="#">Another action</a></li>
-    </ul>
-  </div>
-  <div class="dropdown">
-    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" data-bs-placement="right-end" aria-expanded="false">
-      Right end
-    </button>
-    <ul class="dropdown-menu">
-      <li><a class="dropdown-item" href="#">Action</a></li>
-      <li><a class="dropdown-item" href="#">Another action</a></li>
-    </ul>
-  </div>`} />
+<DropdownPlacementPlayground />
 
 ### Responsive
 
 Change placement at different breakpoints using responsive prefixes. The syntax is `breakpoint:placement`, where breakpoint is one of `sm`, `md`, `lg`, `xl`, or `2xl`. Multiple breakpoints can be combined in a single attribute, space-separated.
 
-For example, `data-bs-placement="bottom-start md:bottom-end lg:right-start"` will:
+For example, `data-bs-placement="bottom-start md:bottom-end lg:end-start"` will:
 
 - Show the menu at `bottom-start` on small screens (default)
 - Switch to `bottom-end` at the `md` breakpoint
@@ -316,16 +184,11 @@ You can use `<a>` or `<button>` elements as dropdown items.
 We use utility classes to display dropdown menus in our docs examples, but they're not required for your own use.
 </Callout>
 
-<Example code={`<div class="dropdown">
-    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false">
-      Dropdown
-    </button>
-    <ul class="dropdown-menu">
-      <li><button class="dropdown-item" type="button">Action</button></li>
-      <li><button class="dropdown-item" type="button">Another action</button></li>
-      <li><button class="dropdown-item" type="button">Something else here</button></li>
-    </ul>
-  </div>`} />
+<Example code={`<ul class="dropdown-menu show position-static">
+    <li><button class="dropdown-item" type="button">Action</button></li>
+    <li><button class="dropdown-item" type="button">Another action</button></li>
+    <li><button class="dropdown-item" type="button">Something else here</button></li>
+  </ul>`} />
 
 You can also create non-interactive dropdown items with `.dropdown-item-text`. Feel free to style further with custom CSS or text utilities.
 
@@ -362,9 +225,10 @@ Add `.disabled` to items in the dropdown to style them as disabled.
 
 Add a header to label sections of actions in any dropdown menu.
 
-<Example code={`<ul class="dropdown-menu">
-    <li><h6 class="dropdown-header">Dropdown header</h6></li>
+<Example code={`<ul class="dropdown-menu show position-static">
     <li><a class="dropdown-item" href="#">Action</a></li>
+    <li><a class="dropdown-item" href="#">Action</a></li>
+    <li><h6 class="dropdown-header">Dropdown header</h6></li>
     <li><a class="dropdown-item" href="#">Another action</a></li>
   </ul>`} />
 
@@ -372,7 +236,7 @@ Add a header to label sections of actions in any dropdown menu.
 
 Separate groups of related menu items with a divider.
 
-<Example code={`<ul class="dropdown-menu">
+<Example code={`<ul class="dropdown-menu show position-static">
     <li><a class="dropdown-item" href="#">Action</a></li>
     <li><a class="dropdown-item" href="#">Another action</a></li>
     <li><a class="dropdown-item" href="#">Something else here</a></li>
@@ -384,7 +248,8 @@ Separate groups of related menu items with a divider.
 
 Place any freeform text within a dropdown menu with text and use [margin]([[docsref:/utilities/margin]]) and [padding]([[docsref:/utilities/padding]]) utilities. Note that you’ll likely need additional sizing styles to constrain the menu width.
 
-<Example code={`<div class="dropdown-menu p-4 text-body-secondary" style="max-width: 200px;">
+<Example code={`<div class="dropdown-menu show position-static p-3 text-body-secondary"
+       style="--bs-dropdown-min-width: 240px;">
     <p>
       Some example text that’s free-flowing within the dropdown menu.
     </p>
@@ -397,54 +262,124 @@ Place any freeform text within a dropdown menu with text and use [margin]([[docs
 
 Put a form within a dropdown menu, or make it into a dropdown menu, and use [margin]([[docsref:/utilities/margin]]) and [padding]([[docsref:/utilities/padding]]) utilities to give it the negative space you require.
 
-<Example code={`<div class="dropdown-menu">
-    <form class="px-4 py-3">
-      <div class="mb-3">
+<Example code={`<div class="dropdown-menu show position-static"
+       style="--bs-dropdown-min-width: 300px;">
+    <form class="d-flex flex-column gap-3 p-3">
+      <div>
         <label for="exampleDropdownFormEmail1" class="form-label">Email address</label>
         <input type="email" class="form-control" id="exampleDropdownFormEmail1" placeholder="email@example.com">
       </div>
-      <div class="mb-3">
+      <div>
         <label for="exampleDropdownFormPassword1" class="form-label">Password</label>
         <input type="password" class="form-control" id="exampleDropdownFormPassword1" placeholder="Password">
       </div>
-      <div class="mb-3">
-        <div class="form-check">
-          <input type="checkbox" class="form-check-input" id="dropdownCheck">
-          <label class="form-check-label" for="dropdownCheck">
-            Remember me
-          </label>
+      <b-checkgroup>
+        <div class="check">
+          <input type="checkbox" id="checkLabel" />
+          <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'>
+            <path class="checked" fill='none' stroke='currentcolor' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/>
+            <path class="indeterminate" fill='none' stroke='currentcolor' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/>
+          </svg>
         </div>
+        <label for="checkLabel">Example new checkbox</label>
+      </b-checkgroup>
+      <div class="vstack gap-2">
+        <button type="submit" class="btn-solid theme-primary">Sign in</button>
+        <a class="btn-ghost theme-secondary" href="#">New around here? Sign up</a>
+        <a class="btn-ghost theme-secondary" href="#">Forgot password?</a>
       </div>
-      <button type="submit" class="btn btn-primary">Sign in</button>
     </form>
-    <div class="dropdown-divider"></div>
-    <a class="dropdown-item" href="#">New around here? Sign up</a>
-    <a class="dropdown-item" href="#">Forgot password?</a>
   </div>`} />
 
+## Submenus
+
+Create nested dropdown menus with the `.dropdown-submenu` wrapper class. Submenus support hover and click activation, keyboard navigation, viewport-aware positioning, and mobile slide-over behavior.
+
+Wrap a `.dropdown-item` trigger and a nested `.dropdown-menu` inside a `.dropdown-submenu` element.
+
 <Example code={`<div class="dropdown">
-    <button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" data-bs-auto-close="outside">
-      Dropdown form
+    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+      Dropdown with submenu
     </button>
-    <form class="dropdown-menu p-4">
-      <div class="mb-3">
-        <label for="exampleDropdownFormEmail2" class="form-label">Email address</label>
-        <input type="email" class="form-control" id="exampleDropdownFormEmail2" placeholder="email@example.com">
-      </div>
-      <div class="mb-3">
-        <label for="exampleDropdownFormPassword2" class="form-label">Password</label>
-        <input type="password" class="form-control" id="exampleDropdownFormPassword2" placeholder="Password">
-      </div>
-      <div class="mb-3">
-        <div class="form-check">
-          <input type="checkbox" class="form-check-input" id="dropdownCheck2">
-          <label class="form-check-label" for="dropdownCheck2">
-            Remember me
-          </label>
-        </div>
-      </div>
-      <button type="submit" class="btn btn-primary">Sign in</button>
-    </form>
+    <ul class="dropdown-menu">
+      <li><a class="dropdown-item" href="#">Action</a></li>
+      <li><a class="dropdown-item" href="#">Another action</a></li>
+      <li class="dropdown-divider"></li>
+      <li class="dropdown-submenu">
+        <button class="dropdown-item" type="button">More options</button>
+        <ul class="dropdown-menu">
+          <li><a class="dropdown-item" href="#">Sub-action 1</a></li>
+          <li><a class="dropdown-item" href="#">Sub-action 2</a></li>
+          <li><a class="dropdown-item" href="#">Sub-action 3</a></li>
+        </ul>
+      </li>
+      <li><a class="dropdown-item" href="#">Something else</a></li>
+    </ul>
+  </div>`} />
+
+### Nested submenus
+
+Submenus can be nested to multiple levels. Each level opens to the side and flips direction when there's not enough viewport space.
+
+<Example code={`<div class="dropdown">
+    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+      Multi-level menu
+    </button>
+    <ul class="dropdown-menu">
+      <li><a class="dropdown-item" href="#">Level 1 action</a></li>
+      <li class="dropdown-submenu">
+        <button class="dropdown-item" type="button">Level 1 submenu</button>
+        <ul class="dropdown-menu">
+          <li><a class="dropdown-item" href="#">Level 2 action</a></li>
+          <li class="dropdown-submenu">
+            <button class="dropdown-item" type="button">Level 2 submenu</button>
+            <ul class="dropdown-menu">
+              <li><a class="dropdown-item" href="#">Level 3 action A</a></li>
+              <li><a class="dropdown-item" href="#">Level 3 action B</a></li>
+            </ul>
+          </li>
+          <li><a class="dropdown-item" href="#">Another level 2</a></li>
+        </ul>
+      </li>
+      <li><a class="dropdown-item" href="#">Another level 1</a></li>
+    </ul>
+  </div>`} />
+
+### Multiple submenus
+
+When multiple submenu triggers exist at the same level, opening one automatically closes the others.
+
+<Example code={`<div class="dropdown">
+    <button class="btn btn-solid theme-secondary" type="button" data-bs-toggle="dropdown" aria-expanded="false">
+      Multiple submenus
+    </button>
+    <ul class="dropdown-menu">
+      <li class="dropdown-submenu">
+        <button class="dropdown-item" type="button">File</button>
+        <ul class="dropdown-menu">
+          <li><a class="dropdown-item" href="#">New</a></li>
+          <li><a class="dropdown-item" href="#">Open</a></li>
+          <li><a class="dropdown-item" href="#">Save</a></li>
+        </ul>
+      </li>
+      <li class="dropdown-submenu">
+        <button class="dropdown-item" type="button">Edit</button>
+        <ul class="dropdown-menu">
+          <li><a class="dropdown-item" href="#">Cut</a></li>
+          <li><a class="dropdown-item" href="#">Copy</a></li>
+          <li><a class="dropdown-item" href="#">Paste</a></li>
+        </ul>
+      </li>
+      <li class="dropdown-submenu">
+        <button class="dropdown-item" type="button">View</button>
+        <ul class="dropdown-menu">
+          <li><a class="dropdown-item" href="#">Zoom In</a></li>
+          <li><a class="dropdown-item" href="#">Zoom Out</a></li>
+        </ul>
+      </li>
+      <li class="dropdown-divider"></li>
+      <li><a class="dropdown-item" href="#">Preferences</a></li>
+    </ul>
   </div>`} />
 
 ## Dropdown options
@@ -525,6 +460,28 @@ By default, the dropdown menu is closed when clicking inside or outside the drop
     </ul>
   </div>`} />
 
+## Accessibility
+
+The [<abbr title="Web Accessibility Initiative">WAI</abbr> <abbr title="Accessible Rich Internet Applications">ARIA</abbr>](https://www.w3.org/TR/wai-aria/) standard defines an actual [`role="menu"` widget](https://www.w3.org/TR/wai-aria/#menu), but this is specific to application-like menus which trigger actions or functions. <abbr title="Accessible Rich Internet Applications">ARIA</abbr> menus can only contain menu items, checkbox menu items, radio button menu items, radio button groups, and sub-menus.
+
+Bootstrap's dropdowns, on the other hand, are designed to be generic and applicable to a variety of situations and markup structures. For instance, it is possible to create dropdowns that contain additional inputs and form controls, such as search fields or login forms. For this reason, Bootstrap does not expect (nor automatically add) any of the `role` and `aria-` attributes required for true <abbr title="Accessible Rich Internet Applications">ARIA</abbr> menus. Authors will have to include these more specific attributes themselves.
+
+### Keyboard navigation
+
+Dropdowns include built-in keyboard support for navigating menu items.
+
+<BsTable>
+| Key | Action |
+| --- | --- |
+| `↓` / `↑` | Navigate to next/previous menu item |
+| `→` | Enter submenu (or `←` in RTL) |
+| `←` | Exit submenu and return to parent (or `→` in RTL) |
+| `Enter` / `Space` | Activate focused item or open submenu |
+| `Esc` | Close the dropdown menu |
+| `Home` / `End` | Jump to first/last menu item |
+| `Tab` | Move focus and close the dropdown |
+</BsTable>
+
 ## CSS
 
 ### Variables
@@ -628,8 +585,10 @@ The dropdown plugin requires the following JavaScript files if you're building B
 | `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. |
-| `placement` | string | `'bottom-start'` | Placement of the dropdown menu. Can be any valid Floating UI placement: `'top'`, `'top-start'`, `'top-end'`, `'bottom'`, `'bottom-start'`, `'bottom-end'`, `'right'`, `'right-start'`, `'right-end'`, `'left'`, `'left-start'`, `'left-end'`. Supports responsive prefixes like `'bottom-start md:bottom-end lg:right'` to change placement at different breakpoints. |
+| `placement` | string | `'bottom-start'` | Placement of the dropdown menu. Physical placements: `'top'`, `'bottom'`, `'left'`, `'right'`. Logical placements (RTL-aware): `'start'`, `'end'`. All support alignment modifiers: `-start`, `-end`. Supports responsive prefixes like `'bottom-start md:end'`. |
 | `reference` | string, element, object | `'toggle'` | Reference element of the dropdown menu. Accepts the values of `'toggle'`, `'parent'`, an HTMLElement reference or an object providing `getBoundingClientRect`. For more information refer to Floating UI's [virtual elements docs](https://floating-ui.com/docs/virtual-elements). |
+| `submenuTrigger` | string | `'both'` | How submenus are triggered. Use `'click'` for click only, `'hover'` for hover only, or `'both'` for both click and hover activation. |
+| `submenuDelay` | number | `100` | Delay in milliseconds before closing a submenu when the mouse leaves. Provides a grace period for diagonal mouse movement toward the submenu. |
 </BsTable>
 
 #### Using function with `floatingConfig`
index 7525e22d1d9d304da8480af464b6ec7b6dae5140..2a0cd6f4a019a7d3dae0c14260816cb173d94ade 100644 (file)
@@ -15,6 +15,7 @@ export declare global {
   export const Code: typeof import('@shortcodes/Code.astro').default
   export const DeprecatedIn: typeof import('@shortcodes/DeprecatedIn.astro').default
   export const Details: typeof import('@shortcodes/Details.astro').default
+  export const DropdownPlacementPlayground: typeof import('@shortcodes/DropdownPlacementPlayground.astro').default
   export const Example: typeof import('@shortcodes/Example.astro').default
   export const JsDismiss: typeof import('@shortcodes/JsDismiss.astro').default
   export const JsDocs: typeof import('@shortcodes/JsDocs.astro').default