'use client'; import * as React from 'react'; import { View, useWindowDimensions, type LayoutChangeEvent } from 'react-native'; import { useMenuRootContext } from '../../menu/root/MenuRootContext'; import { useMenuPortalContext } from '../../menu/portal/MenuPortalContext'; import { MenuPositionerContext } from '../../menu/positioner/MenuPositionerContext'; import { useContextMenuRootContext } from '../root/ContextMenuRootContext'; import { useRenderElement } from '../../use-render/useRenderElement'; import type { Align, PhysicalSide } from '../../utils/useAnchorPositioning'; import type { ZestUIComponentProps } from '../../types'; import { useStoreState } from '../../store/ReactStore'; const NO_ARROW_LAYOUT = () => {}; const NO_ARROW = { current: null }; /** * Positions the context menu popup at the point the long press landed. * Renders a ``. * * Unlike `Menu.Positioner` it anchors to a screen point rather than a trigger * element, so it does not go through `useAnchorPositioning`: floating-ui * measures a real node, and there is no node here. Collision handling is * therefore its own — the popup flips to the other side of the press point when * it would otherwise run off the screen, and is kept `collisionPadding` inside * the edges. `side` and `align` on state report where it actually landed. */ export function ContextMenuPositioner(componentProps: ContextMenuPositioner.Props) { const { render, className, collisionPadding = 5, style, ref, ...elementProps } = componentProps; useMenuPortalContext(); const store = useMenuRootContext(); const { anchor } = useContextMenuRootContext(); const open = useStoreState(store, 'open'); const { width: screenWidth, height: screenHeight } = useWindowDimensions(); // The popup's own size, which only exists after it has been laid out. Until // then it is placed at the press point unflipped, exactly as before. const [size, setSize] = React.useState<{ width: number; height: number } | undefined>(undefined); const onLayout = React.useCallback((event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; setSize((previous) => previous?.width === width && previous?.height === height ? previous : { width, height }, ); }, []); // A closed menu remeasures when it reopens somewhere else, so stale geometry // never decides the first frame of the next open. React.useEffect(() => { if (!open) { setSize(undefined); } }, [open]); const placement = React.useMemo(() => { if (size === undefined) { return { left: anchor.x, top: anchor.y, side: 'bottom' as PhysicalSide, align: 'start' as Align }; } const flipHorizontally = anchor.x + size.width > screenWidth - collisionPadding; const flipVertically = anchor.y + size.height > screenHeight - collisionPadding; const rawLeft = flipHorizontally ? anchor.x - size.width : anchor.x; const rawTop = flipVertically ? anchor.y - size.height : anchor.y; // Clamping matters when the popup is larger than the space on either side: // flipping alone would just move the overflow to the opposite edge. const maxLeft = Math.max(collisionPadding, screenWidth - size.width - collisionPadding); const maxTop = Math.max(collisionPadding, screenHeight - size.height - collisionPadding); return { left: Math.min(Math.max(rawLeft, collisionPadding), maxLeft), top: Math.min(Math.max(rawTop, collisionPadding), maxTop), side: (flipVertically ? 'top' : 'bottom') as PhysicalSide, align: (flipHorizontally ? 'end' : 'start') as Align, }; }, [anchor.x, anchor.y, size, screenWidth, screenHeight, collisionPadding]); const state: ContextMenuPositionerState = { open, side: placement.side, align: placement.align, }; const contextValue: MenuPositionerContext = React.useMemo( () => ({ side: placement.side, align: placement.align, arrowRef: NO_ARROW, arrowStyles: {}, // A context menu is anchored to the point the finger landed, not to an // element, so there is nothing for an arrow to re-measure against. onArrowLayout: NO_ARROW_LAYOUT, }), [placement.side, placement.align], ); const element = useRenderElement(View, componentProps, { state, ref, props: [ { style: { position: 'absolute' as const, left: placement.left, top: placement.top }, onLayout, }, elementProps, ], }); return ( {element} ); } export interface ContextMenuPositionerState { open: boolean; /** * `'bottom'` when the popup hangs below the press point, `'top'` when it was * flipped above it to stay on screen. */ side: PhysicalSide; /** * `'start'` when the popup extends right of the press point, `'end'` when it * was flipped to its left. */ align: Align; } export interface ContextMenuPositionerProps extends ZestUIComponentProps { /** * How much space to keep between the popup and the edges of the screen. * @default 5 */ collisionPadding?: number | undefined; } export namespace ContextMenuPositioner { export type State = ContextMenuPositionerState; export type Props = ContextMenuPositionerProps; }