'use client'; import { type MenuState, PopoverCSSVars } from '@videojs/core'; import { createMenuViewTransition, getAnchorPositionStyle, getMenuViewportAttrs, getMenuViewportElement, getMenuViewTransitionAttrs, getPopupPositionRect, getPositionedSide, getPositioningBoundaryRect, getRootPositionOptions, isEventWithinElement, isMenuNavigationKey, observeMenuViewContent, resolveOffsets, resolvePositioningBoundary, syncMenuViewRoot, syncMenuViewTransition, type UIFocusEvent, type UIKeyboardEvent, } from '@videojs/core/dom'; import { useSnapshot } from '@videojs/store/react'; import { supportsAnchorPositioning } from '@videojs/utils/dom'; import type { CSSProperties } from 'react'; import { forwardRef, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import type { UIComponentProps } from '../../utils/types'; import { useComposedRefs } from '../../utils/use-composed-refs'; import { renderElement } from '../../utils/use-render'; import { useMenuContext, useSubMenuContext } from './context'; export interface MenuContentProps extends UIComponentProps<'div', MenuState> {} const POPOVER_RESET: CSSProperties = { position: 'fixed', inset: 'auto', margin: 0 }; const menuPreventedNativeEvents = new WeakSet(); function toUIKeyboardEvent(event: React.KeyboardEvent): UIKeyboardEvent { return { get defaultPrevented() { return event.defaultPrevented; }, key: event.key, shiftKey: event.shiftKey, ctrlKey: event.ctrlKey, altKey: event.altKey, metaKey: event.metaKey, target: event.target instanceof Node ? event.target : event.currentTarget, currentTarget: event.currentTarget, preventDefault: () => event.preventDefault(), stopPropagation: () => event.stopPropagation(), }; } function toUIFocusEvent(event: React.FocusEvent): UIFocusEvent { return { get defaultPrevented() { return event.defaultPrevented; }, relatedTarget: event.relatedTarget, preventDefault: () => event.preventDefault(), stopPropagation: () => event.stopPropagation(), }; } function preventMenuKeyDefault(event: React.KeyboardEvent): void { const keyboardEvent = toUIKeyboardEvent(event); if (event.key !== 'Escape' && isMenuNavigationKey(keyboardEvent) && !event.defaultPrevented) { event.preventDefault(); menuPreventedNativeEvents.add(event.nativeEvent); } } function wasDefaultPreventedByMenu(event: React.KeyboardEvent): boolean { return menuPreventedNativeEvents.has(event.nativeEvent); } function callKeyDownHandler( handler: React.KeyboardEventHandler | undefined, event: React.KeyboardEvent ): boolean { const defaultPreventedBeforeHandler = event.defaultPrevented && !wasDefaultPreventedByMenu(event); if (!handler) return defaultPreventedBeforeHandler; let defaultPreventedByHandler = false; const preventDefault = event.preventDefault; // Capture-phase menu handling may have already prevented default; track // whether the consumer also calls preventDefault while their handler runs. event.preventDefault = () => { defaultPreventedByHandler = true; preventDefault.call(event); }; try { handler(event); } finally { event.preventDefault = preventDefault; } return defaultPreventedBeforeHandler || defaultPreventedByHandler; } /** Container for menu items. Positioned relative to the trigger at root level; renders in-place as a submenu panel when nested. */ export const MenuContent = forwardRef(function MenuContent( { render, className, style, onKeyDown, onBlur, ...elementProps }, forwardedRef ) { const { core, menu, state, preferredSide, setPositionedSide, stateAttrMap, anchorName, contentId, boundary, container, activeSubMenuId, } = useMenuContext(); const subMenuCtx = useSubMenuContext(); const isSubmenu = state.isSubmenu; const parentMenu = subMenuCtx?.parentMenu ?? null; const subMenuId = subMenuCtx?.subMenuId ?? null; const isActive = isSubmenu && parentMenu !== null && subMenuId !== null ? parentMenu.activeSubMenuId === subMenuId : false; const [menuViewTransition] = useState(() => createMenuViewTransition({ focusFirstItem() { menu.highlightFirstItem({ preventScroll: true }); }, restoreFocus(triggerId) { if (triggerId) { document.getElementById(triggerId)?.focus({ preventScroll: true }); } }, }) ); const menuViewTransitionState = useSnapshot(menuViewTransition.input); const menuViewElementRef = useRef(null); const parentContentElementRef = useRef(null); const activeSubMenuIdRef = useRef(activeSubMenuId); activeSubMenuIdRef.current = activeSubMenuId; useLayoutEffect(() => { return () => menuViewTransition.destroy(); }, [menuViewTransition]); useLayoutEffect(() => { if (!isSubmenu) return; menuViewTransition.sync({ active: isActive, direction: parentMenu?.navigationDirection ?? 'forward', triggerId: parentMenu?.activeSubMenuTriggerId ?? null, }); }, [isActive, isSubmenu, parentMenu, menuViewTransition]); const setMenuViewElement = useCallback( (element: HTMLDivElement | null) => { menuViewElementRef.current = element; menu.setContentElement(element); menuViewTransition.setElement(element); }, [menu, menuViewTransition] ); const handleSubMenuKeyDown = useCallback( (event: React.KeyboardEvent) => { const defaultPreventedByUser = callKeyDownHandler( onKeyDown as React.KeyboardEventHandler | undefined, event ); const keyboardEvent = toUIKeyboardEvent(event); const isNavigationKey = isMenuNavigationKey(keyboardEvent); menu.contentProps.onKeyDown(keyboardEvent); const isBackNavigationKey = event.key === 'ArrowLeft' || event.key === 'Escape'; const ownsActiveSubmenu = parentMenu !== null && subMenuId !== null && parentMenu.menu.navigationInput.current.stack[parentMenu.menu.navigationInput.current.stack.length - 1] ?.menuId === subMenuId; if (isBackNavigationKey && ownsActiveSubmenu && !defaultPreventedByUser) { event.preventDefault(); parentMenu.pop(); } if (isNavigationKey && (!isBackNavigationKey || ownsActiveSubmenu)) { event.stopPropagation(); } }, [onKeyDown, parentMenu, subMenuId, menu] ); const handleRootMenuKeyDown = useCallback( (event: React.KeyboardEvent) => { (onKeyDown as React.KeyboardEventHandler | undefined)?.(event); const keyboardEvent = toUIKeyboardEvent(event); menu.contentProps.onKeyDown(keyboardEvent); if (event.key === 'Escape') return; if (isMenuNavigationKey(keyboardEvent)) { event.stopPropagation(); } }, [onKeyDown, menu] ); const handleRootMenuBlur = useCallback( (event: React.FocusEvent) => { (onBlur as React.FocusEventHandler | undefined)?.(event); menu.contentProps.onFocusOut(toUIFocusEvent(event)); }, [onBlur, menu] ); // ─── Root content state (always declared — Rules of Hooks) ─────────────── const internalRef = useRef(null); const contentRef = useCallback( (element: HTMLDivElement | null) => { if (isSubmenu) return; menu.setContentElement(element); if (element && supportsAnchorPositioning()) { element.style.setProperty('position-anchor', `--${anchorName}`); } }, [isSubmenu, menu, anchorName] ); const rootComposedRef = useComposedRefs(forwardedRef, contentRef, internalRef); const menuViewComposedRef = useComposedRefs(forwardedRef, setMenuViewElement); const positionOptions = useMemo( () => getRootPositionOptions(preferredSide, state.align), [preferredSide, state.align] ); const anchorStyle = useMemo(() => { if (isSubmenu || !positionOptions || !supportsAnchorPositioning()) return null; const { positionAnchor: _, ...rest } = getAnchorPositionStyle(anchorName, positionOptions); return rest as CSSProperties; }, [isSubmenu, anchorName, positionOptions]); const [position, setPosition] = useState(null); useLayoutEffect(() => { if (isSubmenu) return; if (!state.open) return; syncMenuViewRoot(internalRef.current, activeSubMenuId !== null); const contentElement = internalRef.current; if (!contentElement) return; return observeMenuViewContent(contentElement, () => { syncMenuViewRoot(contentElement, activeSubMenuId !== null); }); }, [isSubmenu, state.open, activeSubMenuId]); useLayoutEffect(() => { if (!isSubmenu) return; const parentContentElement = parentMenu?.menu.contentElement ?? parentContentElementRef.current; parentContentElementRef.current = parentContentElement; syncMenuViewTransition(parentContentElement, menuViewElementRef.current, menuViewTransitionState); }); useLayoutEffect(() => { if (isSubmenu) return; if (!state.open) { setPosition(null); return; } if (!positionOptions) { syncMenuViewRoot(internalRef.current, activeSubMenuIdRef.current !== null); return; } const rootPositionOptions = positionOptions; function measure(): void { const triggerElement = menu.triggerElement; const contentElement = internalRef.current; if (!triggerElement || !contentElement) return; const triggerRect = triggerElement.getBoundingClientRect(); const root = contentElement.getRootNode() as Document | ShadowRoot; const boundaryElement = resolvePositioningBoundary(boundary, { container, root }); const anchorSupported = supportsAnchorPositioning(); let contentRect = getPopupPositionRect(contentElement, rootPositionOptions.side); const boundaryRect = getPositioningBoundaryRect(boundaryElement); const offsets = resolveOffsets(contentElement); let side = getPositionedSide(triggerRect, contentRect, boundaryRect, rootPositionOptions, offsets); let nextStyle = getAnchorPositionStyle( anchorName, { ...rootPositionOptions, side }, triggerRect, anchorSupported ? undefined : contentRect, boundaryRect, offsets ); const availableWidth = nextStyle[PopoverCSSVars.availableWidth]; syncMenuViewRoot( contentElement, activeSubMenuIdRef.current !== null, availableWidth ? { availableWidth } : undefined ); if (!anchorSupported) { contentRect = getPopupPositionRect(contentElement, rootPositionOptions.side); side = getPositionedSide(triggerRect, contentRect, boundaryRect, rootPositionOptions, offsets); nextStyle = getAnchorPositionStyle( anchorName, { ...rootPositionOptions, side }, triggerRect, contentRect, boundaryRect, offsets ); } const { positionAnchor: _, ...rootStyle } = nextStyle; setPosition(rootStyle as CSSProperties); setPositionedSide(side); } measure(); const triggerElement = menu.triggerElement; const contentElement = internalRef.current; const boundaryElement = contentElement ? resolvePositioningBoundary(boundary, { container, root: contentElement.getRootNode() as Document | ShadowRoot, }) : null; let animationFrameId = 0; function reposition(event?: Event): void { if (event && isEventWithinElement(event, internalRef.current)) return; cancelAnimationFrame(animationFrameId); animationFrameId = requestAnimationFrame(measure); } reposition(); const resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(() => reposition()) : null; if (triggerElement && resizeObserver) resizeObserver.observe(triggerElement); if (contentElement && resizeObserver) resizeObserver.observe(contentElement); if (boundaryElement && resizeObserver) resizeObserver.observe(boundaryElement); window.addEventListener('scroll', reposition, { capture: true, passive: true }); window.addEventListener('resize', reposition); return () => { cancelAnimationFrame(animationFrameId); resizeObserver?.disconnect(); window.removeEventListener('scroll', reposition, true); window.removeEventListener('resize', reposition); }; }, [isSubmenu, state.open, anchorName, positionOptions, menu, boundary, container, setPositionedSide]); // ─── Render ─────────────────────────────────────────────────────────────── if (isSubmenu) { if (menuViewTransitionState.phase === 'hidden') return null; const subMenuContent = renderElement( 'div', { render, className, style }, { state, ref: menuViewComposedRef, props: [ { ...getMenuViewTransitionAttrs(menuViewTransitionState), role: 'menu' as const, tabIndex: -1, 'data-submenu': '', onKeyDownCapture: preventMenuKeyDefault, onKeyDown: handleSubMenuKeyDown, onBlur, }, elementProps, ], } ); const parentContentElement = parentMenu?.menu.contentElement ?? parentContentElementRef.current; const parentViewportElement = getMenuViewportElement(parentContentElement); return parentViewportElement ? createPortal(subMenuContent, parentViewportElement) : subMenuContent; } if (!state.open) return null; const positioningStyle = position ?? anchorStyle ?? POPOVER_RESET; return renderElement( 'div', { render, className, style }, { state, stateAttrMap, ref: rootComposedRef, props: [ { id: contentId, style: positioningStyle, ...core.getContentAttrs(state), ...getMenuViewportAttrs(), }, { onKeyDownCapture: preventMenuKeyDefault, onKeyDown: handleRootMenuKeyDown, onBlur: handleRootMenuBlur }, elementProps, ], } ); }); export namespace MenuContent { export type Props = MenuContentProps; export type State = MenuState; }