import { memo, type ReactNode, useMemo } from 'react'; import { View, type ViewProps } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { type SharedValue, useAnimatedProps, useAnimatedStyle, withSpring, withTiming } from 'react-native-reanimated'; import { Portal } from '../../portal/Portal/portal'; import { overlayHostPageX, overlayHostPageY } from '../Overlay/overlay-host-position'; import { CONTEXT_MENU_STATE, HOLD_ITEM_TRANSFORM_DURATION, SPRING_CONFIGURATION } from './constants'; import { useHoldMenuInternal } from './context'; import { HoldMenuOverlayPortal } from './hold-menu-overlay-portal'; import type { HoldItemProps, MenuItemProps, TransformOriginAnchorPosition } from './hold-menu-types'; import { calculateMenuHeight, calculateTransformValue } from './layout'; type HoldItemTwinProps = { /** Stable portal name/key — the twin never remounts. */ name: string; children: ReactNode; items: MenuItemProps[]; disableMove: HoldItemProps['disableMove']; closeOnTap: boolean | undefined; isActive: SharedValue; /** Release handover, 0 = active (this twin shows), 1 = released (it is hidden). */ releaseProgress: SharedValue; itemRectY: SharedValue; itemRectX: SharedValue; itemRectWidth: SharedValue; itemRectHeight: SharedValue; itemScale: SharedValue; transformOrigin: SharedValue; }; /** * The portal twin of a `HoldItem`'s children — the lifted copy that takes * over while the menu is open. Upstream's `Portal` + `animatedPortalStyle` + * `PortalOverlay` block from `HoldItem.tsx`, split out so the component stays * under the per-function line limit. * * It is PERMANENTLY MOUNTED (stable `name`/key), positioned at the measured * rect, and toggled by animated opacity plus `pointerEvents` — nothing ever * remounts. While the menu is active it is the visible item, scaled back to 1 * and travelling with the panel (springing to the same `tY` the activating * item published); the in-place original hides under it. A tap-to-close overlay * (upstream's `PortalOverlay`) sits above the children and closes the menu when * `closeOnTap`. The portal host is a plain view, so the always-mounted twin * never blocks page touches. */ const HoldItemTwinComponent = ({ name, children, items, disableMove = false, closeOnTap, isActive, releaseProgress, itemRectY, itemRectX, itemRectWidth, itemRectHeight, itemScale, transformOrigin, }: HoldItemTwinProps) => { const { state, safeAreaInsets, windowSize, rootViewportHeight, rootPageX, rootPageY, teleported } = useHoldMenuInternal(); const overlayTap = useMemo( () => Gesture.Tap().onEnd(() => { if (closeOnTap) state.value = CONTEXT_MENU_STATE.END; }), [closeOnTap, state], ); const animatedPortalStyle = useAnimatedStyle(() => { const menuHeight = calculateMenuHeight(items); // Clamp against the root's VISIBLE extent, not the window's — the two // differ whenever the provider root is inset from the window (storybook's // padding) or taller than it because it sits inside a scroll view (its // measured height is then the full content height). const windowHeight = rootViewportHeight.value || windowSize.value.height; // The same travel the activating item computed and published — upstream // recomputes it here rather than reading the stored `transformValue`. const tY = calculateTransformValue({ itemY: itemRectY.value, itemHeight: itemRectHeight.value, menuHeight, disableMove: disableMove === true, opensBelow: transformOrigin.value.includes('top'), windowHeight, safeTop: safeAreaInsets.value.top, safeBottom: safeAreaInsets.value.bottom, }); // The pair travels while active (spring there) and settles back off-screen // on close — upstream's `transformAnimation`. const transformAnimation = () => { if (disableMove) return 0; if (isActive.value) return withSpring(tY, SPRING_CONFIGURATION); return withTiming(-0.1, { duration: HOLD_ITEM_TRANSFORM_DURATION }); }; // `itemRectX/Y` are root-space (activation subtracts the root's page offset // from the item's page coords). When the twin teleports to the overlay host // its containing block is that host, not the window — and the host itself may // be inset from the window (storybook's chrome, a nested screen). Convert root // space into host space by subtracting the host's own window offset, or the // twin lands too low. const offsetX = teleported ? rootPageX.value - overlayHostPageX.value : 0; const offsetY = teleported ? rootPageY.value - overlayHostPageY.value : 0; return { top: itemRectY.value + offsetY, left: itemRectX.value + offsetX, width: itemRectWidth.value, height: itemRectHeight.value, // `releaseProgress` drives this opacity (0 = active → this twin shows, 1 = // released → hidden). On activation it fades in over the in-place item, // which holds its full opacity until this reaches 1, so the twin never pops // in and the two never overlap semi-transparently (that would dim); on // release it snaps out in lockstep with the in-place item snapping in. When // the twin travels (`tY !== 0`) it lands away from the original, so it // shows fully at once — and the original drops out that same frame, so no // copy is left behind at the item's old spot. opacity: isActive.value && tY !== 0 ? 1 : 1 - releaseProgress.value, transform: [ { translateY: transformAnimation() }, { scale: isActive.value ? withTiming(1, { duration: HOLD_ITEM_TRANSFORM_DURATION }) : itemScale.value, }, ], }; }, [ items, disableMove, isActive, releaseProgress, itemRectY, itemRectX, itemRectWidth, itemRectHeight, itemScale, transformOrigin, safeAreaInsets, windowSize, rootViewportHeight, rootPageX, rootPageY, teleported, ]); const animatedPortalProps = useAnimatedProps(() => ({ pointerEvents: isActive.value ? 'auto' : 'none', })); const twin = ( {/* A decorative duplicate of the in-place item: never announce it (a screen reader would read every entry twice), and keep it out of role/text queries while the real item stays the accessible one. `aria-hidden` is the web spelling RNW reads; the two RN props cover native. They sit on a plain `View`, not the `Animated.View` above, because reanimated drops `aria-*` props on the way to the DOM — the twin would otherwise match role queries alongside the real entry. */} {children} ); // Android lifts the twin out of the `BlurTarget` (through the `BlurProvider` // overlay host, above the menu's layer) so the target-based blur does not // frost it; iOS/web keep the in-tree `Portal` host, which already lifts the // twin above the inline backdrop/menu without leaving the root. The teleport // also carries this item's provider value on the node, because `children` is // arbitrary app content that may read it (a `HoldMenuIcon`, say) and the host // renders it outside the provider's React tree — see `hold-menu-overlay-portal`. return teleported ? {twin} : {twin}; }; export const HoldItemTwin = memo(HoldItemTwinComponent);