This commit is contained in:
Mark Otto 2025-12-27 13:00:06 -08:00 committed by GitHub
commit 4fb399789f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 1314 additions and 20 deletions

View File

@ -46,7 +46,18 @@ 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
// Mobile breakpoint for slide-over mode
const MOBILE_BREAKPOINT = 768
const EVENT_HIDE = `hide${EVENT_KEY}`
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
@ -55,17 +66,23 @@ const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const EVENT_KEYDOWN_DATA_API = `keydown${EVENT_KEY}${DATA_API_KEY}`
const EVENT_KEYUP_DATA_API = `keyup${EVENT_KEY}${DATA_API_KEY}`
const EVENT_MOUSEENTER_DATA_API = `mouseenter${EVENT_KEY}${DATA_API_KEY}`
const EVENT_MOUSELEAVE_DATA_API = `mouseleave${EVENT_KEY}${DATA_API_KEY}`
const CLASS_NAME_SHOW = 'show'
const CLASS_NAME_MOBILE = 'dropdown-menu-mobile'
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 placement with RTL support
const DEFAULT_PLACEMENT = isRTL() ? 'bottom-end' : 'bottom-start'
const SUBMENU_PLACEMENT = isRTL() ? 'left-start' : 'right-start'
const Default = {
autoClose: true,
@ -74,7 +91,11 @@ 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,
mobileBreakpoint: MOBILE_BREAKPOINT
}
const DefaultType = {
@ -84,7 +105,10 @@ const DefaultType = {
offset: '(array|string|function)',
floatingConfig: '(null|object|function)',
placement: 'string',
reference: '(string|element|object)'
reference: '(string|element|object)',
submenuTrigger: 'string',
submenuDelay: 'number',
mobileBreakpoint: 'number'
}
/**
@ -103,6 +127,12 @@ 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
this._mobileMenuStack = [] // Stack of mobile submenus for back navigation
// 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 +140,9 @@ class Dropdown extends BaseComponent {
// Parse responsive placements on init
this._parseResponsivePlacements()
// Set up submenu event listeners
this._setupSubmenuListeners()
}
// Getters
@ -158,10 +191,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 +214,8 @@ class Dropdown extends BaseComponent {
dispose() {
this._disposeFloating()
this._disposeMediaQueryListeners()
this._closeAllSubmenus()
this._clearAllSubmenuTimeouts()
super.dispose()
}
@ -196,6 +232,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 +247,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')
@ -335,21 +375,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 +458,381 @@ class Dropdown extends BaseComponent {
}
}
// -------------------------------------------------------------------------
// 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
// Check if we should use mobile mode
if (this._isMobileMode()) {
this._openSubmenuMobile(trigger, submenu, submenuWrapper)
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) {
// Use the submenuWrapper as reference for positioning
const referenceElement = submenuWrapper
const updatePosition = async () => {
if (!submenu.isConnected) return
const placement = SUBMENU_PLACEMENT
const middleware = [
// Small negative offset to overlap slightly with parent menu
offset({ mainAxis: 0, crossAxis: -4 }),
// Flip to opposite side if not enough space
flip({
fallbackPlacements: isRTL() ?
['right-start', 'left-end', 'right-end'] :
['left-start', 'right-end', 'left-end']
}),
// Shift to keep in viewport
shift({ padding: 8 })
]
const { x, y, placement: finalPlacement } = await computePosition(
referenceElement,
submenu,
{ placement, middleware }
)
if (!submenu.isConnected) return
Object.assign(submenu.style, {
position: 'absolute',
left: `${x}px`,
top: `${y}px`,
margin: '0'
})
Manipulator.setDataAttribute(submenu, 'placement', finalPlacement)
}
// Initial position
updatePosition()
// Auto-update on scroll/resize
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 sign = (p1, p2, p3) =>
(p1.x - p3.x) * (p2.y - p3.y) - (p2.x - p3.x) * (p1.y - p3.y)
const d1 = sign(point, v1, v2)
const d2 = sign(point, v2, v3)
const d3 = sign(point, v3, v1)
const hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0)
const hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0)
return !(hasNeg && hasPos)
}
// -------------------------------------------------------------------------
// Mobile mode
// -------------------------------------------------------------------------
_isMobileMode() {
return window.innerWidth < this._config.mobileBreakpoint
}
_openSubmenuMobile(trigger, submenu, submenuWrapper) {
// Add mobile class for slide-over animation
submenu.classList.add(CLASS_NAME_MOBILE)
// Create back button header if not exists
if (!submenu.querySelector('.dropdown-mobile-header')) {
const header = document.createElement('div')
header.className = 'dropdown-mobile-header'
const backBtn = document.createElement('button')
backBtn.type = 'button'
backBtn.className = 'dropdown-back-btn'
backBtn.setAttribute('aria-label', 'Back')
const title = document.createElement('span')
title.textContent = trigger.textContent.trim()
header.append(backBtn, title)
submenu.prepend(header)
// Back button handler
EventHandler.on(backBtn, 'click', () => {
this._closeSubmenuMobile(submenu, submenuWrapper, trigger)
})
}
// Set ARIA
trigger.setAttribute('aria-expanded', 'true')
// Show with animation
submenu.classList.add(CLASS_NAME_SHOW)
submenuWrapper.classList.add(CLASS_NAME_SHOW)
// Track in stack
this._mobileMenuStack.push({ submenu, submenuWrapper, trigger })
// Focus first item in submenu
requestAnimationFrame(() => {
const firstItem = SelectorEngine.findOne(SELECTOR_VISIBLE_ITEMS, submenu)
if (firstItem && !firstItem.closest('.dropdown-mobile-header')) {
firstItem.focus()
}
})
}
_closeSubmenuMobile(submenu, submenuWrapper, trigger) {
submenu.classList.remove(CLASS_NAME_SHOW)
submenuWrapper.classList.remove(CLASS_NAME_SHOW)
trigger.setAttribute('aria-expanded', 'false')
// Remove from stack
this._mobileMenuStack = this._mobileMenuStack.filter(
item => item.submenu !== submenu
)
// Focus back to trigger
trigger.focus()
// Clean up mobile class after animation
setTimeout(() => {
submenu.classList.remove(CLASS_NAME_MOBILE)
// Remove the header
const header = submenu.querySelector('.dropdown-mobile-header')
if (header) {
header.remove()
}
}, 200)
}
// -------------------------------------------------------------------------
// 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)
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) {
return
@ -430,6 +843,113 @@ 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) {
if (this._isMobileMode()) {
this._openSubmenuMobile(target, submenu, submenuWrapper)
} else {
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) {
if (this._isMobileMode()) {
this._openSubmenuMobile(target, submenu, submenuWrapper)
} else {
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)
if (this._isMobileMode() && this._mobileMenuStack.length > 0) {
const stackItem = this._mobileMenuStack[this._mobileMenuStack.length - 1]
this._closeSubmenuMobile(stackItem.submenu, stackItem.submenuWrapper, stackItem.trigger)
} else {
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 +989,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 +1009,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 +1016,53 @@ 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)) {
if (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) {
// Check if we're in mobile mode with stack
if (instance._isMobileMode() && instance._mobileMenuStack.length > 0) {
const stackItem = instance._mobileMenuStack[instance._mobileMenuStack.length - 1]
instance._closeSubmenuMobile(stackItem.submenu, stackItem.submenuWrapper, stackItem.trigger)
return
}
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()
}

View File

@ -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>

View 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,145 @@ $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);
}
}
// Mobile slide-over mode for submenus
.dropdown-menu-mobile {
--dropdown-mobile-header-height: 3rem;
position: fixed !important; // stylelint-disable-line declaration-no-important
inset: 0;
z-index: calc(var(--dropdown-zindex) + 10);
display: flex;
flex-direction: column;
width: 100%;
max-width: none;
height: 100%;
max-height: 100dvh;
padding-top: 0;
overflow-y: auto;
visibility: hidden;
background-color: var(--dropdown-bg);
border: 0;
@include border-radius(0);
opacity: 0;
transform: translateX(100%);
@include transition(transform .2s ease-out, opacity .2s ease-out, visibility .2s);
[dir="rtl"] & {
transform: translateX(-100%);
}
&.show {
visibility: visible;
opacity: 1;
transform: translateX(0);
}
// Back button header for mobile submenus
.dropdown-mobile-header {
position: sticky;
top: 0;
z-index: 1;
display: flex;
gap: .5rem;
align-items: center;
min-height: var(--dropdown-mobile-header-height);
padding: .5rem var(--dropdown-item-padding-x);
font-weight: $font-weight-semibold;
background-color: var(--dropdown-bg);
border-bottom: var(--dropdown-border-width) solid var(--dropdown-border-color);
}
.dropdown-back-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
color: var(--dropdown-link-color);
cursor: pointer;
background: transparent;
border: 0;
@include border-radius(var(--dropdown-item-border-radius));
&:hover,
&:focus {
color: var(--dropdown-link-hover-color);
background-color: var(--dropdown-link-hover-bg);
}
// Back arrow icon
&::before {
display: block;
width: .5em;
height: .5em;
content: "";
border-color: currentcolor;
border-style: solid;
border-width: .125em .125em 0 0;
transform: rotate(-135deg);
[dir="rtl"] & {
transform: rotate(45deg);
}
}
}
}
// scss-docs-end dropdown-submenu
}

View 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;

View File

@ -447,6 +447,114 @@ Put a form within a dropdown menu, or make it into a dropdown menu, and use [mar
</form>
</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.
### Basic submenu
Wrap a `.dropdown-item` trigger and a nested `.dropdown-menu` inside a `.dropdown-submenu` element.
<Example code={`<div class="dropdown">
<button class="btn btn-solid theme-secondary" 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>`} />
### 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>`} />
### Submenu keyboard navigation
Submenus are fully keyboard accessible:
- <kbd>↓</kbd> <kbd>↑</kbd> — Navigate within the current menu level
- <kbd>→</kbd> — Enter a submenu (or <kbd>←</kbd> in RTL)
- <kbd>←</kbd> — Exit a submenu and return to parent (or <kbd>→</kbd> in RTL)
- <kbd>Enter</kbd> or <kbd>Space</kbd> — Activate item or open submenu
- <kbd>Esc</kbd> — Close current submenu or entire dropdown
- <kbd>Home</kbd> <kbd>End</kbd> — Jump to first/last item
### 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>`} />
### Mobile behavior
On viewports narrower than `768px` (configurable via `mobileBreakpoint`), submenus use a slide-over pattern with a back button for navigation instead of floating positioning.
## Dropdown options
Use `data-bs-offset` or `data-bs-reference` to change the location of the dropdown.
@ -630,6 +738,9 @@ The dropdown plugin requires the following JavaScript files if you're building B
| `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. |
| `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. |
| `mobileBreakpoint` | number | `768` | Viewport width in pixels below which submenus use slide-over mobile mode instead of floating positioning. |
</BsTable>
#### Using function with `floatingConfig`