| /** |
| * -------------------------------------------------------------------------- |
| * Bootstrap menu.ts |
| * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) |
| * -------------------------------------------------------------------------- |
| */ |
| |
| import { |
| computePosition, |
| flip, |
| shift, |
| offset, |
| autoUpdate, |
| type Boundary, |
| type Middleware, |
| type MiddlewareState, |
| type Placement, |
| type ReferenceElement, |
| type Strategy |
| } from '@floating-ui/dom' |
| import BaseComponent from './base-component.js' |
| import EventHandler, { type BootstrapEvent } from './dom/event-handler.js' |
| import Manipulator from './dom/manipulator.js' |
| import SelectorEngine from './dom/selector-engine.js' |
| import type { ComponentConfig } from './util/config.js' |
| import { |
| execute, |
| getElement, |
| getNextActiveElement, |
| getTransitionDurationFromElement, |
| isDisabled, |
| isElement, |
| isRTL, |
| isVisible, |
| noop |
| } from './util/index.js' |
| import { |
| parseResponsivePlacement, |
| getResponsivePlacement, |
| createBreakpointListeners, |
| disposeBreakpointListeners, |
| toFloatingOffset, |
| type BreakpointListener, |
| type ResponsivePlacements, |
| type FloatingOffsetOption, |
| type FloatingConfigOption |
| } from './util/floating-ui.js' |
| |
| /** |
| * Constants |
| */ |
| |
| const NAME = 'menu' |
| const DATA_KEY = 'bs.menu' |
| const EVENT_KEY = `.${DATA_KEY}` |
| const DATA_API_KEY = '.data-api' |
| |
| const ESCAPE_KEY = 'Escape' |
| const TAB_KEY = 'Tab' |
| const ARROW_UP_KEY = 'ArrowUp' |
| const ARROW_DOWN_KEY = 'ArrowDown' |
| 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 |
| |
| const SUBMENU_CLOSE_DELAY = 100 |
| |
| const EVENT_HIDE = `hide${EVENT_KEY}` |
| const EVENT_HIDDEN = `hidden${EVENT_KEY}` |
| const EVENT_SHOW = `show${EVENT_KEY}` |
| 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 CLASS_NAME_SHOW = 'show' |
| |
| const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="menu"]:not(.disabled):not(:disabled)' |
| |
| const SELECTOR_MENU = '.menu' |
| const SELECTOR_SUBMENU = '.submenu' |
| const SELECTOR_SUBMENU_TOGGLE = '.submenu > .menu-item' |
| const SELECTOR_NAVBAR_NAV = '.navbar-nav' |
| const SELECTOR_VISIBLE_ITEMS = '.menu-item:not(.disabled):not(:disabled)' |
| |
| const DEFAULT_PLACEMENT = 'bottom-start' |
| const SUBMENU_PLACEMENT = 'end-start' |
| |
| const resolveLogicalPlacement = (placement: string): string => { |
| if (isRTL()) { |
| return placement.replace(/^start(?=-|$)/, 'right').replace(/^end(?=-|$)/, 'left') |
| } |
| |
| return placement.replace(/^start(?=-|$)/, 'left').replace(/^end(?=-|$)/, 'right') |
| } |
| |
| type Point = { x: number, y: number } |
| |
| const triangleSign = (p1: Point, p2: Point, p3: Point): number => |
| ((p1.x - p3.x) * (p2.y - p3.y)) - ((p2.x - p3.x) * (p1.y - p3.y)) |
| |
| type MenuConfig = { |
| autoClose: boolean | 'inside' | 'outside' |
| boundary: string | Element |
| container: string | Element | boolean |
| display: string |
| offset: FloatingOffsetOption |
| floatingConfig: FloatingConfigOption |
| menu: HTMLElement | null |
| placement: string |
| reference: string | Element | Record<string, any> |
| strategy: string |
| submenuTrigger: string |
| submenuDelay: number |
| } |
| |
| const Default: MenuConfig = { |
| autoClose: true, |
| boundary: 'clippingParents', |
| container: false, |
| display: 'dynamic', |
| offset: [0, 2], |
| floatingConfig: null, |
| menu: null, |
| placement: DEFAULT_PLACEMENT, |
| reference: 'toggle', |
| strategy: 'absolute', |
| submenuTrigger: 'both', |
| submenuDelay: SUBMENU_CLOSE_DELAY |
| } |
| |
| const DefaultType = { |
| autoClose: '(boolean|string)', |
| boundary: '(string|element)', |
| container: '(string|element|boolean)', |
| display: 'string', |
| offset: '(array|string|function)', |
| floatingConfig: '(null|object|function)', |
| menu: '(null|element)', |
| placement: 'string', |
| reference: '(string|element|object)', |
| strategy: 'string', |
| submenuTrigger: 'string', |
| submenuDelay: 'number' |
| } |
| |
| /** |
| * Class definition |
| */ |
| |
| class Menu extends BaseComponent { |
| static _openInstances = new Set<Menu>() |
| protected declare _config: MenuConfig |
| protected declare _floatingCleanup: (() => void) | null |
| protected declare _mediaQueryListeners: BreakpointListener[] |
| protected declare _responsivePlacements: ResponsivePlacements | null |
| protected declare _parent: HTMLElement |
| protected declare _openSubmenus: Map<HTMLElement, () => void> |
| protected declare _submenuCloseTimeouts: Map<HTMLElement, number> |
| protected declare _hoverIntentData: { x: number, y: number, timestamp: number } | null |
| protected declare _menu: HTMLElement |
| protected declare _isSubmenu: boolean |
| protected declare _menuOriginalParent: ParentNode | null |
| |
| constructor(element?: string | Element | null, config?: Partial<MenuConfig> | null) { |
| if (typeof computePosition === 'undefined') { |
| throw new TypeError('Bootstrap\'s menus require Floating UI (https://floating-ui.com)') |
| } |
| |
| super(element, config) |
| |
| this._floatingCleanup = null |
| this._mediaQueryListeners = [] |
| this._responsivePlacements = null |
| this._parent = this._element.parentNode as HTMLElement // menu wrapper |
| this._openSubmenus = new Map() |
| this._submenuCloseTimeouts = new Map() |
| this._hoverIntentData = null |
| |
| this._menu = (this._config.menu || this._findMenu()) as HTMLElement |
| |
| // When the menu was discovered from the DOM, refine the wrapper to the closest |
| // ancestor that actually contains it, so the toggle doesn't have to be a direct |
| // sibling of `.menu` (e.g. when wrapped by web components). The wrapper still |
| // receives `.show` and acts as the `reference: 'parent'` positioning anchor. |
| if (!this._config.menu && this._menu) { |
| this._parent = this._findWrapper(this._menu) |
| } |
| |
| this._isSubmenu = this._parent.classList?.contains('submenu') |
| |
| this._menuOriginalParent = this._menu?.parentNode |
| |
| this._parseResponsivePlacements() |
| this._setupSubmenuListeners() |
| this._initSubmenuTriggers() |
| } |
| |
| // Getters |
| static override get Default(): MenuConfig { |
| return Default |
| } |
| |
| static override get DefaultType(): Record<string, string> { |
| return DefaultType |
| } |
| |
| static override get NAME(): string { |
| return NAME |
| } |
| |
| // Public |
| toggle(): Promise<void> { |
| return this._isShown() ? this.hide() : this.show() |
| } |
| |
| async show(): Promise<void> { |
| if (isDisabled(this._element) || this._isShown()) { |
| return |
| } |
| |
| const relatedTarget = { |
| relatedTarget: this._element |
| } |
| |
| const showEvent = EventHandler.trigger(this._element, EVENT_SHOW, relatedTarget) |
| |
| if (showEvent.defaultPrevented) { |
| return |
| } |
| |
| this._moveMenuToContainer() |
| this._createFloating() |
| |
| if ('ontouchstart' in document.documentElement && !this._parent.closest(SELECTOR_NAVBAR_NAV)) { |
| for (const element of document.body.children) { |
| EventHandler.on(element, 'mouseover', noop) |
| } |
| } |
| |
| this._element.focus({ focusVisible: false }) |
| this._element.setAttribute('aria-expanded', 'true') |
| |
| this._menu.classList.add(CLASS_NAME_SHOW) |
| this._element.classList.add(CLASS_NAME_SHOW) |
| |
| if (this._parent) { |
| this._parent.classList.add(CLASS_NAME_SHOW) |
| } |
| |
| Menu._openInstances.add(this) |
| |
| // Wait for the CSS entry transition (opacity/transform) to finish before |
| // announcing `shown`, so listeners run against the settled menu. |
| await this._queueCallback(() => { |
| // Bail if the menu was hidden again mid-transition. |
| if (this._isShown()) { |
| EventHandler.trigger(this._element, EVENT_SHOWN, relatedTarget) |
| } |
| }, this._menu, this._isAnimated()) |
| } |
| |
| async hide(): Promise<void> { |
| if (isDisabled(this._element) || !this._isShown()) { |
| return |
| } |
| |
| const relatedTarget = { |
| relatedTarget: this._element |
| } |
| |
| await this._completeHide(relatedTarget) |
| } |
| |
| override dispose(): void { |
| this._disposeFloating() |
| this._restoreMenuToOriginalParent() |
| this._disposeMediaQueryListeners() |
| this._closeAllSubmenus() |
| this._clearAllSubmenuTimeouts() |
| Menu._openInstances.delete(this) |
| super.dispose() |
| } |
| |
| update(): void { |
| if (this._floatingCleanup) { |
| this._updateFloatingPosition() |
| } |
| } |
| |
| // Private |
| protected _findMenu(): Element | null { |
| // Fall back to the closest ancestor that contains a menu so the toggle can be |
| // nested deeper than a direct sibling of `.menu`. |
| const wrapper = SelectorEngine.closest(this._element, `:has(${SELECTOR_MENU})`) |
| return SelectorEngine.next(this._element, SELECTOR_MENU)[0] || |
| SelectorEngine.prev(this._element, SELECTOR_MENU)[0] || |
| SelectorEngine.findOne(SELECTOR_MENU, wrapper || this._parent) |
| } |
| |
| protected _findWrapper(menu: HTMLElement): HTMLElement { |
| let wrapper = this._element.parentNode |
| while (wrapper instanceof Element && !wrapper.contains(menu)) { |
| wrapper = wrapper.parentNode |
| } |
| |
| return (wrapper instanceof Element ? wrapper : this._element.parentNode) as HTMLElement |
| } |
| |
| protected async _completeHide(relatedTarget: Record<string, unknown>): Promise<void> { |
| const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE, relatedTarget) |
| if (hideEvent.defaultPrevented) { |
| return |
| } |
| |
| this._closeAllSubmenus() |
| |
| if ('ontouchstart' in document.documentElement) { |
| for (const element of document.body.children) { |
| EventHandler.off(element, 'mouseover', noop) |
| } |
| } |
| |
| // Start the exit transition by removing `.show`. Keep Floating UI active and |
| // the menu in its current DOM location so it animates out in place; the |
| // teardown (dispose floating, restore original parent) is deferred until the |
| // transition ends. Doing it now would move/reposition the menu mid-fade and |
| // make the exit animation appear to snap. |
| this._menu.classList.remove(CLASS_NAME_SHOW) |
| this._element.classList.remove(CLASS_NAME_SHOW) |
| |
| if (this._parent) { |
| this._parent.classList.remove(CLASS_NAME_SHOW) |
| } |
| |
| this._element.setAttribute('aria-expanded', 'false') |
| Menu._openInstances.delete(this) |
| |
| await this._queueCallback(() => { |
| // Bail if the menu was reopened during the exit transition. |
| if (this._isShown()) { |
| return |
| } |
| |
| this._disposeFloating() |
| this._restoreMenuToOriginalParent() |
| Manipulator.removeDataAttribute(this._menu, 'placement') |
| Manipulator.removeDataAttribute(this._menu, 'display') |
| EventHandler.trigger(this._element, EVENT_HIDDEN, relatedTarget) |
| }, this._menu, this._isAnimated()) |
| } |
| |
| protected override _getConfig(config?: ComponentConfig | null): ComponentConfig { |
| config = super._getConfig(config) |
| |
| if (typeof config.reference === 'object' && !isElement(config.reference) && |
| typeof config.reference.getBoundingClientRect !== 'function' |
| ) { |
| throw new TypeError(`${NAME.toUpperCase()}: Option "reference" provided type "object" without a required "getBoundingClientRect" method.`) |
| } |
| |
| return config |
| } |
| |
| protected _createFloating(): void { |
| if (this._config.display === 'static') { |
| Manipulator.setDataAttribute(this._menu, 'display', 'static') |
| return |
| } |
| |
| let referenceElement: ReferenceElement = this._element |
| |
| if (this._config.reference === 'parent') { |
| referenceElement = this._parent |
| } else if (isElement(this._config.reference)) { |
| referenceElement = getElement(this._config.reference)! |
| } else if (typeof this._config.reference === 'object') { |
| referenceElement = this._config.reference as ReferenceElement |
| } |
| |
| this._updateFloatingPosition(referenceElement) |
| |
| this._floatingCleanup = autoUpdate( |
| referenceElement, |
| this._menu, |
| () => this._updateFloatingPosition(referenceElement) |
| ) |
| } |
| |
| protected async _updateFloatingPosition(referenceElement: ReferenceElement | null = null): Promise<void> { |
| if (!this._menu) { |
| return |
| } |
| |
| if (!referenceElement) { |
| if (this._config.reference === 'parent') { |
| referenceElement = this._parent |
| } else if (isElement(this._config.reference)) { |
| referenceElement = getElement(this._config.reference) |
| } else if (typeof this._config.reference === 'object') { |
| referenceElement = this._config.reference as ReferenceElement |
| } else { |
| referenceElement = this._element |
| } |
| } |
| |
| const placement = this._getPlacement() |
| const middleware = this._getFloatingMiddleware() |
| const floatingConfig = this._getFloatingConfig(placement, middleware) |
| |
| await this._applyFloatingPosition( |
| referenceElement!, |
| this._menu, |
| floatingConfig.placement, |
| floatingConfig.middleware, |
| floatingConfig.strategy |
| ) |
| } |
| |
| protected _isShown(): boolean { |
| return this._menu.classList.contains(CLASS_NAME_SHOW) |
| } |
| |
| protected _isAnimated(): boolean { |
| // The menu always defines its transition in CSS; treat a zero computed |
| // duration (e.g. reduced motion or disabled transitions) as non-animated so |
| // callbacks run synchronously. |
| return getTransitionDurationFromElement(this._menu) > 0 |
| } |
| |
| protected _getPlacement(): string { |
| const placement = this._responsivePlacements ? |
| getResponsivePlacement(this._responsivePlacements, DEFAULT_PLACEMENT) : |
| this._config.placement |
| |
| return resolveLogicalPlacement(placement) |
| } |
| |
| protected _parseResponsivePlacements(): void { |
| this._responsivePlacements = parseResponsivePlacement(this._config.placement, DEFAULT_PLACEMENT) |
| |
| if (this._responsivePlacements) { |
| this._setupMediaQueryListeners() |
| } |
| } |
| |
| protected _setupMediaQueryListeners(): void { |
| this._disposeMediaQueryListeners() |
| this._mediaQueryListeners = createBreakpointListeners(() => { |
| if (this._isShown()) { |
| this._updateFloatingPosition() |
| } |
| }) |
| } |
| |
| protected _disposeMediaQueryListeners(): void { |
| disposeBreakpointListeners(this._mediaQueryListeners) |
| this._mediaQueryListeners = [] |
| } |
| |
| protected _getOffset(): number[] | ((state: MiddlewareState) => any) { |
| const { offset: offsetConfig } = this._config |
| |
| if (typeof offsetConfig === 'string') { |
| return offsetConfig.split(',').map(value => Number.parseInt(value, 10)) |
| } |
| |
| if (typeof offsetConfig === 'function') { |
| return ({ placement, rects }) => { |
| const result = offsetConfig({ placement, reference: rects.reference, floating: rects.floating }, this._element) |
| return toFloatingOffset(result) |
| } |
| } |
| |
| return offsetConfig |
| } |
| |
| protected _getFloatingMiddleware(): Middleware[] { |
| const offsetValue = this._getOffset() |
| |
| const middleware = [ |
| offset( |
| typeof offsetValue === 'function' ? |
| offsetValue : |
| toFloatingOffset(offsetValue) |
| ), |
| flip({ |
| fallbackPlacements: this._getFallbackPlacements(), |
| // When no placement fits, keep the initial one. Overflow below can be |
| // scrolled to; overflow above the scroll origin can never be reached. |
| fallbackStrategy: 'initialPlacement' |
| }), |
| shift({ |
| boundary: (this._config.boundary === 'clippingParents' ? 'clippingAncestors' : this._config.boundary) as Boundary |
| }) |
| ] |
| |
| return middleware |
| } |
| |
| protected _getFallbackPlacements(): Placement[] { |
| const placement = this._getPlacement() |
| |
| const fallbackMap: Record<string, Placement[]> = { |
| bottom: ['top', 'bottom-start', 'bottom-end', 'top-start', 'top-end'], |
| 'bottom-start': ['top-start', 'bottom-end', 'top-end'], |
| 'bottom-end': ['top-end', 'bottom-start', 'top-start'], |
| top: ['bottom', 'top-start', 'top-end', 'bottom-start', 'bottom-end'], |
| 'top-start': ['bottom-start', 'top-end', 'bottom-end'], |
| 'top-end': ['bottom-end', 'top-start', 'bottom-start'], |
| right: ['left', 'right-start', 'right-end', 'left-start', 'left-end'], |
| 'right-start': ['left-start', 'right-end', 'left-end', 'top-start', 'bottom-start'], |
| 'right-end': ['left-end', 'right-start', 'left-start', 'top-end', 'bottom-end'], |
| left: ['right', 'left-start', 'left-end', 'right-start', 'right-end'], |
| 'left-start': ['right-start', 'left-end', 'right-end', 'top-start', 'bottom-start'], |
| 'left-end': ['right-end', 'left-start', 'right-start', 'top-end', 'bottom-end'] |
| } |
| |
| return fallbackMap[placement] || ['top', 'bottom', 'right', 'left'] |
| } |
| |
| protected _getFloatingConfig(placement: string, middleware: Middleware[]): Record<string, any> { |
| const defaultConfig = { |
| placement, |
| middleware, |
| strategy: this._config.strategy |
| } |
| |
| return { |
| ...defaultConfig, |
| ...execute(this._config.floatingConfig, [undefined, defaultConfig]) |
| } |
| } |
| |
| protected _disposeFloating(): void { |
| if (this._floatingCleanup) { |
| this._floatingCleanup() |
| this._floatingCleanup = null |
| } |
| } |
| |
| protected _getContainer(): HTMLElement | null { |
| const { container } = this._config |
| if (container === false) { |
| return null |
| } |
| |
| return container === true ? document.body : getElement(container) |
| } |
| |
| protected _moveMenuToContainer(): void { |
| const container = this._getContainer() |
| if (!container || !this._menu) { |
| return |
| } |
| |
| if (this._menu.parentNode !== container) { |
| container.append(this._menu) |
| } |
| } |
| |
| protected _restoreMenuToOriginalParent(): void { |
| if (!this._menuOriginalParent || !this._menu) { |
| return |
| } |
| |
| if (this._menu.parentNode !== this._menuOriginalParent) { |
| this._menuOriginalParent.append(this._menu) |
| } |
| } |
| |
| protected async _applyFloatingPosition(reference: ReferenceElement, floating: HTMLElement, placement: Placement, middleware: Middleware[], strategy: Strategy = 'absolute'): Promise<string | null> { |
| if (!floating.isConnected) { |
| return null |
| } |
| |
| const { x, y, placement: finalPlacement } = await computePosition( |
| reference, |
| floating, |
| { placement, middleware, strategy } |
| ) |
| |
| if (!floating.isConnected) { |
| return null |
| } |
| |
| Object.assign(floating.style, { |
| position: strategy, |
| left: `${x}px`, |
| top: `${y}px`, |
| margin: '0' |
| }) |
| |
| Manipulator.setDataAttribute(floating, 'placement', finalPlacement) |
| return finalPlacement |
| } |
| |
| // ------------------------------------------------------------------------- |
| // Submenu handling |
| // ------------------------------------------------------------------------- |
| |
| protected _setupSubmenuListeners(): void { |
| 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) |
| }) |
| |
| EventHandler.on(this._menu, 'mousemove', event => { |
| this._trackMousePosition(event) |
| }) |
| } |
| |
| if (this._config.submenuTrigger === 'click' || this._config.submenuTrigger === 'both') { |
| EventHandler.on(this._menu, 'click', SELECTOR_SUBMENU_TOGGLE, event => { |
| this._onSubmenuTriggerClick(event) |
| }) |
| } |
| } |
| |
| protected _onSubmenuTriggerEnter(event: BootstrapEvent): void { |
| const trigger = (event.target as Element).closest<HTMLElement>(SELECTOR_SUBMENU_TOGGLE) |
| if (!trigger) { |
| return |
| } |
| |
| const submenuWrapper = trigger.closest(SELECTOR_SUBMENU)! |
| const submenu = SelectorEngine.findOne(SELECTOR_MENU, submenuWrapper) |
| if (!submenu) { |
| return |
| } |
| |
| this._cancelSubmenuCloseTimeout(submenu) |
| this._closeSiblingSubmenus(submenuWrapper) |
| this._openSubmenu(trigger, submenu, submenuWrapper) |
| } |
| |
| protected _onSubmenuLeave(event: BootstrapEvent): void { |
| const submenuWrapper = (event.target as Element).closest(SELECTOR_SUBMENU)! |
| const submenu = SelectorEngine.findOne(SELECTOR_MENU, submenuWrapper) |
| if (!submenu || !this._openSubmenus.has(submenu)) { |
| return |
| } |
| |
| if (this._isMovingTowardSubmenu(event, submenu)) { |
| return |
| } |
| |
| this._scheduleSubmenuClose(submenu, submenuWrapper) |
| } |
| |
| protected _onSubmenuTriggerClick(event: BootstrapEvent): void { |
| const trigger = (event.target as Element).closest<HTMLElement>(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 |
| } |
| |
| if (this._openSubmenus.has(submenu)) { |
| this._closeSubmenu(submenu, submenuWrapper) |
| } else { |
| this._closeSiblingSubmenus(submenuWrapper) |
| this._openSubmenu(trigger, submenu, submenuWrapper) |
| } |
| } |
| |
| // Announce every submenu trigger up front. Waiting until the submenu opens hides |
| // the popup from assistive technology until a user has already found it. |
| protected _initSubmenuTriggers(): void { |
| if (!this._menu) { |
| return |
| } |
| |
| for (const trigger of SelectorEngine.find(SELECTOR_SUBMENU_TOGGLE, this._menu)) { |
| if (!SelectorEngine.findOne(SELECTOR_MENU, trigger.parentElement!)) { |
| continue |
| } |
| |
| trigger.setAttribute('aria-haspopup', 'true') |
| |
| if (!trigger.hasAttribute('aria-expanded')) { |
| trigger.setAttribute('aria-expanded', 'false') |
| } |
| } |
| } |
| |
| protected _openSubmenu(trigger: HTMLElement, submenu: HTMLElement, submenuWrapper: Element): void { |
| if (this._openSubmenus.has(submenu)) { |
| return |
| } |
| |
| trigger.setAttribute('aria-expanded', 'true') |
| trigger.setAttribute('aria-haspopup', 'true') |
| |
| // Keep the submenu transparent until Floating UI applies the first position, so |
| // it doesn't flash at its CSS fallback position (top: 0, over the parent menu) |
| // before being moved into place. `opacity` (unlike `visibility`/`display`) keeps |
| // the submenu measurable for flip/shift and focusable for keyboard navigation. |
| submenu.style.opacity = '0' |
| submenu.classList.add(CLASS_NAME_SHOW) |
| submenuWrapper.classList.add(CLASS_NAME_SHOW) |
| |
| const cleanup = this._createSubmenuFloating(trigger, submenu, submenuWrapper) |
| this._openSubmenus.set(submenu, cleanup) |
| |
| EventHandler.on(submenu, 'mouseenter', () => { |
| this._cancelSubmenuCloseTimeout(submenu) |
| }) |
| } |
| |
| protected _closeSubmenu(submenu: HTMLElement, submenuWrapper: Element): void { |
| if (!this._openSubmenus.has(submenu)) { |
| return |
| } |
| |
| 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) |
| } |
| |
| const trigger = SelectorEngine.findOne(SELECTOR_SUBMENU_TOGGLE, submenuWrapper) |
| |
| const cleanup = this._openSubmenus.get(submenu) |
| if (cleanup) { |
| cleanup() |
| } |
| |
| this._openSubmenus.delete(submenu) |
| EventHandler.off(submenu, 'mouseenter') |
| |
| if (trigger) { |
| trigger.setAttribute('aria-expanded', 'false') |
| } |
| |
| submenu.classList.remove(CLASS_NAME_SHOW) |
| submenuWrapper.classList.remove(CLASS_NAME_SHOW) |
| |
| // Keep the Floating UI position styles in place while the submenu fades out. |
| // Clearing them here would let the submenu snap back to its CSS fallback |
| // (`top: 0`, over the parent menu) for the duration of the close transition, |
| // causing it to flash over the parent. They get recomputed on the next open |
| // (and the opacity gate in `_openSubmenu` hides any stale position until then). |
| submenu.style.opacity = '' |
| } |
| |
| protected _closeAllSubmenus(): void { |
| for (const [submenu] of this._openSubmenus) { |
| const submenuWrapper = submenu.closest(SELECTOR_SUBMENU)! |
| this._closeSubmenu(submenu, submenuWrapper) |
| } |
| } |
| |
| protected _closeSiblingSubmenus(currentSubmenuWrapper: Element): void { |
| 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) |
| } |
| } |
| } |
| |
| protected _createSubmenuFloating(trigger: HTMLElement, submenu: HTMLElement, submenuWrapper: Element): () => void { |
| const referenceElement = submenuWrapper |
| const placement = resolveLogicalPlacement(SUBMENU_PLACEMENT) as Placement |
| const middleware = [ |
| offset({ mainAxis: 0, crossAxis: -4 }), |
| flip({ |
| fallbackPlacements: [ |
| resolveLogicalPlacement('start-start'), |
| resolveLogicalPlacement('end-end'), |
| resolveLogicalPlacement('start-end') |
| ] as Placement[] |
| }), |
| shift({ padding: 8 }) |
| ] |
| |
| const updatePosition = () => this._applyFloatingPosition(referenceElement, submenu, placement, middleware) |
| .then(finalPlacement => { |
| // Reveal the submenu now that it has been positioned (see `_openSubmenu`); |
| // clearing the inline opacity lets the CSS fade-in transition take over. |
| submenu.style.opacity = '' |
| return finalPlacement |
| }) |
| |
| updatePosition() |
| return autoUpdate(referenceElement, submenu, updatePosition) |
| } |
| |
| protected _scheduleSubmenuClose(submenu: HTMLElement, submenuWrapper: Element): void { |
| this._cancelSubmenuCloseTimeout(submenu) |
| |
| const timeoutId = setTimeout(() => { |
| this._closeSubmenu(submenu, submenuWrapper) |
| this._submenuCloseTimeouts.delete(submenu) |
| }, this._config.submenuDelay) |
| |
| this._submenuCloseTimeouts.set(submenu, timeoutId) |
| } |
| |
| protected _cancelSubmenuCloseTimeout(submenu: HTMLElement): void { |
| const timeoutId = this._submenuCloseTimeouts.get(submenu) |
| if (timeoutId) { |
| clearTimeout(timeoutId) |
| this._submenuCloseTimeouts.delete(submenu) |
| } |
| } |
| |
| protected _clearAllSubmenuTimeouts(): void { |
| for (const timeoutId of this._submenuCloseTimeouts.values()) { |
| clearTimeout(timeoutId) |
| } |
| |
| this._submenuCloseTimeouts.clear() |
| } |
| |
| // ------------------------------------------------------------------------- |
| // Hover intent / Safe triangle |
| // ------------------------------------------------------------------------- |
| |
| protected _trackMousePosition(event: BootstrapEvent): void { |
| this._hoverIntentData = { |
| x: event.clientX, |
| y: event.clientY, |
| timestamp: Date.now() |
| } |
| } |
| |
| protected _isMovingTowardSubmenu(event: BootstrapEvent, submenu: HTMLElement): boolean { |
| 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 } |
| |
| const isRtl = isRTL() |
| const targetX = isRtl ? submenuRect.right : submenuRect.left |
| const topCorner = { x: targetX, y: submenuRect.top } |
| const bottomCorner = { x: targetX, y: submenuRect.bottom } |
| |
| return this._pointInTriangle(currentPos, lastPos, topCorner, bottomCorner) |
| } |
| |
| protected _pointInTriangle(point: Point, v1: Point, v2: Point, v3: Point): boolean { |
| const d1 = triangleSign(point, v1, v2) |
| const d2 = triangleSign(point, v2, v3) |
| const d3 = triangleSign(point, v3, v1) |
| |
| const hasNeg = (d1 < 0) || (d2 < 0) || (d3 < 0) |
| const hasPos = (d1 > 0) || (d2 > 0) || (d3 > 0) |
| |
| return !(hasNeg && hasPos) |
| } |
| |
| // ------------------------------------------------------------------------- |
| // Keyboard navigation |
| // ------------------------------------------------------------------------- |
| |
| // Items sit either directly in `.menu`, or as a submenu trigger one level deeper |
| // (`.menu > .submenu > .menu-item`). Both are navigable at the same level. |
| protected _getItemsInMenu(menu: Element): HTMLElement[] { |
| return SelectorEngine.find( |
| `:scope > ${SELECTOR_VISIBLE_ITEMS}, :scope > ${SELECTOR_SUBMENU} > ${SELECTOR_VISIBLE_ITEMS}`, |
| menu |
| ).filter(element => isVisible(element)) |
| } |
| |
| protected _selectMenuItem({ key, target }: BootstrapEvent): void { |
| const currentMenu = (target as Element).closest(SELECTOR_MENU) || this._menu |
| const items = this._getItemsInMenu(currentMenu) |
| |
| if (!items.length) { |
| return |
| } |
| |
| const nextItem = getNextActiveElement(items, target as HTMLElement, key === ARROW_DOWN_KEY, !items.includes(target as HTMLElement)) |
| nextItem.focus() |
| this._closeUnrelatedSubmenus(currentMenu, nextItem) |
| } |
| |
| // Moving focus along one level closes the submenus focus left behind, matching |
| // what hover and click already do through `_closeSiblingSubmenus`. |
| protected _closeUnrelatedSubmenus(currentMenu: Element, focusedElement: HTMLElement): void { |
| for (const [submenu] of this._openSubmenus) { |
| const submenuWrapper = submenu.closest(SELECTOR_SUBMENU) |
| |
| if (!submenuWrapper || submenuWrapper.parentElement !== currentMenu || submenuWrapper.contains(focusedElement)) { |
| continue |
| } |
| |
| this._closeSubmenu(submenu, submenuWrapper) |
| } |
| } |
| |
| protected _handleSubmenuKeydown(event: BootstrapEvent): boolean { |
| const { key, target } = event |
| const isRtl = isRTL() |
| |
| const enterKey = isRtl ? ARROW_LEFT_KEY : ARROW_RIGHT_KEY |
| const exitKey = isRtl ? ARROW_RIGHT_KEY : ARROW_LEFT_KEY |
| |
| const submenuWrapper = (target as Element).closest(SELECTOR_SUBMENU) |
| const isSubmenuToggle = (target as Element).matches(SELECTOR_SUBMENU_TOGGLE) |
| |
| const isOpenKey = key === ENTER_KEY || key === SPACE_KEY || key === enterKey |
| |
| if (isOpenKey && submenuWrapper && isSubmenuToggle) { |
| event.preventDefault() |
| event.stopPropagation() |
| |
| const submenu = SelectorEngine.findOne(SELECTOR_MENU, submenuWrapper) |
| if (submenu) { |
| this._closeSiblingSubmenus(submenuWrapper) |
| this._openSubmenu(target as HTMLElement, submenu, submenuWrapper) |
| requestAnimationFrame(() => { |
| const firstItem = SelectorEngine.findOne(SELECTOR_VISIBLE_ITEMS, submenu) |
| if (firstItem) { |
| firstItem.focus() |
| } |
| }) |
| } |
| |
| return true |
| } |
| |
| if (key === exitKey) { |
| const currentMenu = (target as Element).closest(SELECTOR_MENU) |
| const parentSubmenuWrapper = currentMenu?.closest(SELECTOR_SUBMENU) |
| |
| if (parentSubmenuWrapper) { |
| event.preventDefault() |
| event.stopPropagation() |
| |
| const parentTrigger = SelectorEngine.findOne(SELECTOR_SUBMENU_TOGGLE, parentSubmenuWrapper) |
| this._closeSubmenu(currentMenu as HTMLElement, parentSubmenuWrapper) |
| if (parentTrigger) { |
| parentTrigger.focus() |
| } |
| |
| return true |
| } |
| } |
| |
| if (key === HOME_KEY || key === END_KEY) { |
| event.preventDefault() |
| event.stopPropagation() |
| |
| const currentMenu = (target as Element).closest(SELECTOR_MENU)! |
| const items = this._getItemsInMenu(currentMenu) |
| |
| if (items.length) { |
| const targetItem = key === HOME_KEY ? items[0] : items.at(-1)! |
| targetItem.focus() |
| this._closeUnrelatedSubmenus(currentMenu, targetItem) |
| } |
| |
| return true |
| } |
| |
| return false |
| } |
| |
| static clearMenus(event: BootstrapEvent): void { |
| if (event.button === RIGHT_MOUSE_BUTTON || (event.type === 'keyup' && event.key !== TAB_KEY)) { |
| return |
| } |
| |
| for (const instance of Menu._openInstances) { |
| if (instance._config.autoClose === false) { |
| continue |
| } |
| |
| const composedPath = event.composedPath() |
| const isMenuTarget = composedPath.includes(instance._menu) |
| if ( |
| composedPath.includes(instance._element) || |
| (instance._config.autoClose === 'inside' && !isMenuTarget) || |
| (instance._config.autoClose === 'outside' && isMenuTarget) |
| ) { |
| continue |
| } |
| |
| // Don't auto-close when interacting with a form inside the menu — clicks |
| // on a form's labels, buttons, etc. (not just inputs) should keep it open. |
| const formAncestor = (event.target as HTMLElement).closest?.('form') |
| const isInsideMenuForm = Boolean(formAncestor) && instance._menu.contains(formAncestor!) |
| if (instance._menu.contains(event.target as HTMLElement) && ((event.type === 'keyup' && event.key === TAB_KEY) || |
| /input|select|option|textarea|form/i.test((event.target as HTMLElement).tagName) || isInsideMenuForm)) { |
| continue |
| } |
| |
| const relatedTarget: Record<string, unknown> = { relatedTarget: instance._element } |
| |
| if (event.type === 'click') { |
| relatedTarget.clickEvent = event |
| } |
| |
| instance._completeHide(relatedTarget) |
| } |
| } |
| |
| // The keydown handler is delegated on both the toggle and `.menu`, and delegation |
| // stops at the innermost match. So for a key pressed inside a submenu panel, or |
| // inside a menu moved to `container`, the toggle is not a sibling of the element |
| // the handler ran on. Ask the open instances which menu owns the event first, and |
| // only then fall back to the sibling and wrapper lookups. |
| protected static _getToggleFromKeydownContext(element: HTMLElement, event: BootstrapEvent): HTMLElement | null { |
| if (element.matches(SELECTOR_DATA_TOGGLE)) { |
| return element |
| } |
| |
| for (const instance of Menu._openInstances) { |
| if (instance._element === event.target || instance._menu?.contains(event.target as Node)) { |
| return instance._element |
| } |
| } |
| |
| return SelectorEngine.prev(element, SELECTOR_DATA_TOGGLE)[0] as HTMLElement || |
| SelectorEngine.next(element, SELECTOR_DATA_TOGGLE)[0] as HTMLElement || |
| SelectorEngine.findOne(SELECTOR_DATA_TOGGLE, event.delegateTarget.parentNode) |
| } |
| |
| static dataApiKeydownHandler(this: HTMLElement, event: BootstrapEvent): void { |
| // Treat contenteditable hosts (e.g. rich-text editors) like inputs so the |
| // menu doesn't hijack their arrow keys. |
| const isInput = /input|textarea/i.test((event.target as HTMLElement).tagName) || (event.target as HTMLElement).isContentEditable |
| 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) |
| |
| const isSubmenuTrigger = (event.target as Element).matches(SELECTOR_SUBMENU_TOGGLE) |
| |
| if (!isUpOrDownEvent && !isEscapeEvent && !isLeftOrRightEvent && !isHomeOrEndEvent && |
| !(isEnterOrSpaceEvent && isSubmenuTrigger)) { |
| return |
| } |
| |
| if (isInput && !isEscapeEvent) { |
| return |
| } |
| |
| const getToggleButton = Menu._getToggleFromKeydownContext(this, event) |
| |
| if (!getToggleButton) { |
| return |
| } |
| |
| const instance = Menu.getOrCreateInstance(getToggleButton) |
| |
| if ((isLeftOrRightEvent || isHomeOrEndEvent || (isEnterOrSpaceEvent && isSubmenuTrigger)) && instance._handleSubmenuKeydown(event)) { |
| return |
| } |
| |
| if (isUpOrDownEvent) { |
| event.preventDefault() |
| event.stopPropagation() |
| instance.show() |
| instance._selectMenuItem(event) |
| return |
| } |
| |
| if (isEscapeEvent && instance._isShown()) { |
| event.preventDefault() |
| event.stopPropagation() |
| |
| const currentMenu = (event.target as Element).closest(SELECTOR_MENU) |
| const parentSubmenuWrapper = currentMenu?.closest(SELECTOR_SUBMENU) |
| |
| if (parentSubmenuWrapper && instance._openSubmenus.size > 0) { |
| const parentTrigger = SelectorEngine.findOne(SELECTOR_SUBMENU_TOGGLE, parentSubmenuWrapper) |
| instance._closeSubmenu(currentMenu as HTMLElement, parentSubmenuWrapper) |
| if (parentTrigger) { |
| parentTrigger.focus() |
| } |
| |
| return |
| } |
| |
| instance.hide() |
| getToggleButton.focus() |
| } |
| } |
| } |
| |
| /** |
| * Data API implementation |
| */ |
| |
| EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_DATA_TOGGLE, Menu.dataApiKeydownHandler) |
| EventHandler.on(document, EVENT_KEYDOWN_DATA_API, SELECTOR_MENU, Menu.dataApiKeydownHandler) |
| EventHandler.on(document, EVENT_CLICK_DATA_API, Menu.clearMenus) |
| EventHandler.on(document, EVENT_KEYUP_DATA_API, Menu.clearMenus) |
| EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (event) { |
| event.preventDefault() |
| Menu.getOrCreateInstance(this).toggle() |
| }) |
| |
| export default Menu |
| export type { MenuConfig } |