Files
v10/packages/react/src/ui/menu/menu-content.tsx
T

442 lines
14 KiB
TypeScript

'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<Event>();
function toUIKeyboardEvent(event: React.KeyboardEvent<HTMLDivElement>): 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<HTMLDivElement>): UIFocusEvent {
return {
get defaultPrevented() {
return event.defaultPrevented;
},
relatedTarget: event.relatedTarget,
preventDefault: () => event.preventDefault(),
stopPropagation: () => event.stopPropagation(),
};
}
function preventMenuKeyDefault(event: React.KeyboardEvent<HTMLDivElement>): void {
const keyboardEvent = toUIKeyboardEvent(event);
if (event.key !== 'Escape' && isMenuNavigationKey(keyboardEvent) && !event.defaultPrevented) {
event.preventDefault();
menuPreventedNativeEvents.add(event.nativeEvent);
}
}
function wasDefaultPreventedByMenu(event: React.KeyboardEvent<HTMLDivElement>): boolean {
return menuPreventedNativeEvents.has(event.nativeEvent);
}
function callKeyDownHandler(
handler: React.KeyboardEventHandler<HTMLDivElement> | undefined,
event: React.KeyboardEvent<HTMLDivElement>
): 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<HTMLDivElement, MenuContentProps>(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<HTMLDivElement | null>(null);
const parentContentElementRef = useRef<HTMLElement | null>(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<HTMLDivElement>) => {
const defaultPreventedByUser = callKeyDownHandler(
onKeyDown as React.KeyboardEventHandler<HTMLDivElement> | 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<HTMLDivElement>) => {
(onKeyDown as React.KeyboardEventHandler<HTMLDivElement> | 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<HTMLDivElement>) => {
(onBlur as React.FocusEventHandler<HTMLDivElement> | undefined)?.(event);
menu.contentProps.onFocusOut(toUIFocusEvent(event));
},
[onBlur, menu]
);
// ─── Root content state (always declared — Rules of Hooks) ───────────────
const internalRef = useRef<HTMLDivElement>(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<CSSProperties | null>(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;
}