mirror of
https://github.com/twbs/bootstrap.git
synced 2025-12-28 13:40:43 +00:00
Merge 4a96998718 into 5b1a686d3a
This commit is contained in:
commit
4fb399789f
@ -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()
|
||||
}
|
||||
|
||||
482
js/tests/visual/dropdown-submenu.html
Normal file
482
js/tests/visual/dropdown-submenu.html
Normal 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 <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>
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user