import { MenuCore, MenuDataAttrs, type MenuInput, POPUP_HOST_ATTR } from '@videojs/core'; import { applyElementProps, applyStateDataAttrs, createMenu, createMenuViewTransition, createTransition, getAnchorNameStyle, getAnchorPositionStyle, getMenuViewportAttrs, getMenuViewTransitionAttrs, getPopupPositionRect, getPositioningBoundaryRect, getRootPositionOptions, isMenuNavigationKey, type MenuApi, type MenuChangeDetails, type MenuOpenChangeReason, type MenuViewTransitionState, type NavigationState, observeMenuViewContent, type PositioningBoundary, resolveOffsets, resolvePositioningBoundary, syncMenuViewRoot, syncMenuViewTransition, type UIFocusEvent, type UIKeyboardEvent, } from '@videojs/core/dom'; import type { PropertyDeclarationMap, PropertyValues } from '@videojs/element'; import { ContextConsumer, ContextProvider } from '@videojs/element/context'; import { SnapshotController } from '@videojs/store/html'; import { applyStyles, supportsAnchorPositioning, tryHidePopover, tryShowPopover } from '@videojs/utils/dom'; import { containerContext } from '../../player/context'; import { MediaElement } from '../media-element'; import { PositionController } from '../position-controller'; import { type MenuContextValue, menuContext } from './context'; export class MenuElement extends MediaElement { static readonly tagName: string = 'media-menu'; static override properties = { open: { type: Boolean }, defaultOpen: { type: Boolean, attribute: 'default-open' }, side: { type: String }, align: { type: String }, closeOnEscape: { type: Boolean, attribute: 'close-on-escape' }, closeOnOutsideClick: { type: Boolean, attribute: 'close-on-outside-click' }, boundary: { type: String }, } satisfies PropertyDeclarationMap< 'open' | 'defaultOpen' | 'side' | 'align' | 'closeOnEscape' | 'closeOnOutsideClick' | 'boundary' >; open = MenuCore.defaultProps.open; defaultOpen = MenuCore.defaultProps.defaultOpen; side = MenuCore.defaultProps.side; align = MenuCore.defaultProps.align; closeOnEscape = MenuCore.defaultProps.closeOnEscape; closeOnOutsideClick = MenuCore.defaultProps.closeOnOutsideClick; boundary: PositioningBoundary = 'container'; readonly #core = new MenuCore(); readonly #provider = new ContextProvider(this, { context: menuContext }); readonly #position = new PositionController(this); readonly #containerCtx = new ContextConsumer(this, { context: containerContext, subscribe: true }); // Consume parent menu context — present when this is a nested (submenu) element. readonly #parentCtx = new ContextConsumer(this, { context: menuContext, subscribe: true }); readonly #menuViewTransition = createMenuViewTransition({ focusFirstItem: () => { this.#menu?.highlightFirstItem({ preventScroll: true }); }, restoreFocus: (triggerId) => { const triggerElement = triggerId ? document.getElementById(triggerId) : null; const fallbackTrigger = this.parentElement?.querySelector( `[data-has-submenu][commandfor="${this.id}"]` ); (triggerElement ?? fallbackTrigger)?.focus({ preventScroll: true }); }, }); #menu: MenuApi | null = null; #snapshot: SnapshotController | null = null; #navSnapshot: SnapshotController | null = null; #menuViewSnapshot: SnapshotController | null = null; #navState: NavigationState = { stack: [], direction: 'forward' }; #disconnect: AbortController | null = null; #triggerAbort: AbortController | null = null; #cleanupContentObserver: (() => void) | null = null; #currentTrigger: HTMLElement | null = null; override connectedCallback(): void { super.connectedCallback(); if (this.destroyed) return; this.setAttribute(POPUP_HOST_ATTR, ''); this.#disconnect = new AbortController(); this.#menu = createMenu({ transition: createTransition(), onOpenChange: (nextOpen: boolean, details: MenuChangeDetails) => { this.open = nextOpen; this.dispatchEvent(new CustomEvent('open-change', { detail: { open: nextOpen, ...details } })); }, closeOnEscape: () => this.closeOnEscape, closeOnOutsideClick: () => this.closeOnOutsideClick, group: () => (this.#parentCtx.value ? undefined : this.#containerCtx.value?.popupGroup), }); // The element itself is the content (popup) for root menus. // Submenu detection happens in update() once parent context is available. this.#menu.setContentElement(this); applyElementProps( this, { onKeyDown: this.#handleContentKeyDown, onFocusOut: this.#handleContentFocusOut }, { signal: this.#disconnect.signal } ); if (this.#snapshot) { this.#snapshot.track(this.#menu.input); } else { this.#snapshot = new SnapshotController(this, this.#menu.input); } if (this.#navSnapshot) { this.#navSnapshot.track(this.#menu.navigationInput); } else { this.#navSnapshot = new SnapshotController(this, this.#menu.navigationInput); } if (this.#menuViewSnapshot) { this.#menuViewSnapshot.track(this.#menuViewTransition.input); } else { this.#menuViewSnapshot = new SnapshotController(this, this.#menuViewTransition.input); } } protected override firstUpdated(changed: PropertyValues): void { super.firstUpdated(changed); if (this.defaultOpen && !this.open) { this.#menu?.open(); } } override disconnectedCallback(): void { super.disconnectedCallback(); this.#cleanupContentObserver?.(); this.#cleanupContentObserver = null; this.#cleanupTrigger(); this.#menu?.destroy(); this.#menu = null; this.#disconnect?.abort(); this.#disconnect = null; this.#menuViewTransition.destroy(); } close(reason: MenuOpenChangeReason = 'imperative-action'): void { this.#menu?.close(reason); } protected override willUpdate(changed: PropertyValues): void { super.willUpdate(changed); const parentCtx = this.#parentCtx.value ?? null; const isSubmenu = parentCtx !== null; this.#core.setProps({ open: this.open, defaultOpen: this.defaultOpen, side: this.side, align: this.align, closeOnEscape: this.closeOnEscape, closeOnOutsideClick: this.closeOnOutsideClick, isSubmenu, }); if (this.#menu && changed.has('open') && !isSubmenu) { const { active: interactionOpen } = this.#menu.input.current; if (this.open !== interactionOpen) { if (this.open) { this.#menu.open(); } else { this.#menu.close(); } } } } protected override update(_changed: PropertyValues): void { super.update(_changed); if (!this.#menu) return; const parentCtx = this.#parentCtx.value ?? null; const isSubmenu = parentCtx !== null; this.#navState = this.#menu.navigationInput.current; const input = this.#menu.input.current; this.#core.setInput(input); const state = this.#core.getState(); if (isSubmenu && parentCtx) { this.#updateAsSubmenu(parentCtx); } else { this.#updateAsRoot(state); } // Provide context to child parts. // When nested, expose the parent menu's API so items can pop on select. const parentMenu = parentCtx?.menu ?? null; this.#provider.setValue({ menu: this.#menu, state, stateAttrMap: MenuDataAttrs, navigation: this.#navState, parentMenu, }); } #updateAsRoot(state: ReturnType): void { if (!this.#menu) return; const triggerElement = this.#position.findTrigger(); this.#syncTrigger(triggerElement); applyElementProps(this, { ...this.#core.getContentAttrs(state), ...getMenuViewportAttrs(), }); applyStateDataAttrs(this, state, MenuDataAttrs); if (state.open) { tryShowPopover(this); } else { tryHidePopover(this); } if (this.#currentTrigger) { applyElementProps(this.#currentTrigger, this.#core.getTriggerAttrs(state, this.id)); applyStyles(this.#currentTrigger, getAnchorNameStyle(this.id)); } if (!state.open) { this.#cleanupContentObserver?.(); this.#cleanupContentObserver = null; this.#position.cleanup(); return; } this.#cleanupContentObserver ??= observeMenuViewContent(this, () => { this.requestUpdate(); }); syncMenuViewRoot(this, this.#navState.stack.length > 0); const positionOptions = getRootPositionOptions(state.side, state.align); if (!positionOptions) return; const boundaryElement = this.#getBoundaryElement(); const triggerRect = this.#currentTrigger?.getBoundingClientRect(); const boundaryRect = getPositioningBoundaryRect(boundaryElement); const offsets = resolveOffsets(this); if (supportsAnchorPositioning()) { applyStyles( this, getAnchorPositionStyle(this.id, positionOptions, triggerRect, undefined, boundaryRect, offsets) ); } else { const selfRect = getPopupPositionRect(this); applyStyles(this, getAnchorPositionStyle(this.id, positionOptions, triggerRect, selfRect, boundaryRect, offsets)); } this.#position.sync(this.#currentTrigger, boundaryElement); } #updateAsSubmenu(parentCtx: MenuContextValue): void { const parentNavigation = parentCtx.navigation; const topEntry = parentNavigation.stack[parentNavigation.stack.length - 1]; const activeSubMenuId = topEntry?.menuId ?? null; const isActive = activeSubMenuId === this.id; this.#menuViewTransition.setElement(this); this.#menuViewTransition.sync({ active: isActive, direction: parentNavigation.direction, triggerId: topEntry?.triggerId ?? null, }); // Apply base submenu attributes regardless of phase. const transitionState = this.#menuViewTransition.input.current; this.removeAttribute(MenuDataAttrs.side); this.removeAttribute(MenuDataAttrs.align); applyElementProps(this, { ...getMenuViewTransitionAttrs(transitionState), role: 'menu', tabIndex: -1, 'data-submenu': '', }); syncMenuViewTransition(parentCtx.menu.contentElement, this, transitionState); } #handleContentKeyDown = (event: UIKeyboardEvent): void => { const isNavigationKey = isMenuNavigationKey(event); const defaultPreventedBeforeMenu = event.defaultPrevented; this.#menu?.contentProps.onKeyDown(event); const parentCtx = this.#parentCtx.value ?? null; if (!parentCtx) { if (event.key === 'Escape') return; if (isNavigationKey) { event.stopPropagation(); } return; } const stack = parentCtx.menu.navigationInput.current.stack; const topEntry = stack[stack.length - 1]; const ownsActiveSubmenu = topEntry?.menuId === this.id; const isBackNavigationKey = event.key === 'ArrowLeft' || event.key === 'Escape'; if (isBackNavigationKey && ownsActiveSubmenu && !defaultPreventedBeforeMenu) { event.preventDefault(); parentCtx.menu.pop(); } if (isNavigationKey && (!isBackNavigationKey || ownsActiveSubmenu)) { event.stopPropagation(); } }; #handleContentFocusOut = (event: UIFocusEvent): void => { this.#menu?.contentProps.onFocusOut(event); }; #syncTrigger(triggerElement: HTMLElement | null): void { if (triggerElement === this.#currentTrigger) return; this.#position.cleanup(); this.#cleanupTrigger(); this.#currentTrigger = triggerElement; this.#menu?.setTriggerElement(triggerElement); if (triggerElement && this.#menu) { this.#triggerAbort = new AbortController(); applyElementProps(triggerElement, this.#menu.triggerProps, { signal: this.#triggerAbort.signal }); } } #cleanupTrigger(): void { if (this.#currentTrigger) { applyElementProps(this.#currentTrigger, { 'aria-expanded': undefined, 'aria-haspopup': undefined, 'aria-controls': undefined, }); this.#currentTrigger.style.removeProperty('anchor-name'); } this.#triggerAbort?.abort(); this.#triggerAbort = null; this.#currentTrigger = null; } #getBoundaryElement(): Element | null { return resolvePositioningBoundary(this.boundary, { container: this.#containerCtx.value?.container ?? null, root: this.getRootNode() as Document | ShadowRoot, }); } }