import { type ComponentProps, createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type PropsWithChildren, type ReactElement, type Ref, type MutableRefObject, } from "react"; import { ActivityIndicator, Dimensions, Modal, Platform, Pressable, ScrollView, StatusBar, Text, View, type PressableProps, type StyleProp, type ViewStyle, } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; import { Check, CheckCircle } from "lucide-react-native"; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from "@gorhom/bottom-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { isWeb, isNative } from "@/constants/platform"; import { useWebScrollbarStyle } from "@/hooks/use-web-scrollbar-style"; // Keep parity with dropdown-menu action statuses. export type ActionStatus = "idle" | "pending" | "success"; type Placement = "top" | "bottom" | "left" | "right"; type Alignment = "start" | "center" | "end"; type MobileMenuMode = "dropdown" | "sheet"; interface Rect { x: number; y: number; width: number; height: number; } type ContextMenuContextValue = { open: boolean; setOpen: (open: boolean) => void; triggerRef: React.RefObject; anchorRect: Rect | null; setAnchorRect: (rect: Rect | null) => void; }; const ContextMenuContext = createContext(null); function useContextMenuContext(componentName: string): ContextMenuContextValue { const ctx = useContext(ContextMenuContext); if (!ctx) { throw new Error(`${componentName} must be used within `); } return ctx; } export function useContextMenu(): ContextMenuContextValue { return useContextMenuContext("useContextMenu"); } function useControllableOpenState({ open, defaultOpen, onOpenChange, }: { open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; }): [boolean, (next: boolean) => void] { const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen)); const isControlled = typeof open === "boolean"; const value = isControlled ? Boolean(open) : internalOpen; const setValue = useCallback( (next: boolean) => { if (!isControlled) setInternalOpen(next); onOpenChange?.(next); }, [isControlled, onOpenChange], ); return [value, setValue]; } function measureElement(element: View): Promise { return new Promise((resolve) => { element.measureInWindow((x, y, width, height) => { resolve({ x, y, width, height }); }); }); } function computePosition({ triggerRect, contentSize, displayArea, placement, alignment, offset, }: { triggerRect: Rect; contentSize: { width: number; height: number }; displayArea: Rect; placement: Placement; alignment: Alignment; offset: number; }): { x: number; y: number; actualPlacement: Placement } { const { width: contentWidth, height: contentHeight } = contentSize; // Calculate available space const spaceTop = triggerRect.y - displayArea.y; const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height); // Flip if needed let actualPlacement = placement; if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) { actualPlacement = "top"; } else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) { actualPlacement = "bottom"; } let x: number; let y: number; // Position based on placement if (actualPlacement === "bottom") { y = triggerRect.y + triggerRect.height + offset; } else if (actualPlacement === "top") { y = triggerRect.y - contentHeight - offset; } else if (actualPlacement === "left") { x = triggerRect.x - contentWidth - offset; y = triggerRect.y; } else { x = triggerRect.x + triggerRect.width + offset; y = triggerRect.y; } // Alignment if (actualPlacement === "top" || actualPlacement === "bottom") { if (alignment === "start") { x = triggerRect.x; } else if (alignment === "end") { x = triggerRect.x + triggerRect.width - contentWidth; } else { x = triggerRect.x + (triggerRect.width - contentWidth) / 2; } } // Constrain to screen const padding = 8; x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!)); y = Math.max( displayArea.y + padding, Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!), ); return { x, y, actualPlacement }; } function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null { const nativeEvent: any = (event as any)?.nativeEvent ?? event; const pageX = nativeEvent?.pageX; const pageY = nativeEvent?.pageY; if (typeof pageX === "number" && typeof pageY === "number") { return { pageX, pageY }; } const clientX = nativeEvent?.clientX; const clientY = nativeEvent?.clientY; if (typeof clientX === "number" && typeof clientY === "number") { return { pageX: clientX, pageY: clientY }; } return null; } function assignRef(ref: Ref | undefined, value: T): void { if (typeof ref === "function") { ref(value); return; } if (ref && typeof ref === "object") { (ref as MutableRefObject).current = value; } } export function ContextMenu({ open, defaultOpen, onOpenChange, children, }: PropsWithChildren<{ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; }>): ReactElement { const triggerRef = useRef(null); const [isOpen, setIsOpen] = useControllableOpenState({ open, defaultOpen, onOpenChange, }); const [anchorRect, setAnchorRect] = useState(null); useEffect(() => { if (!isOpen) { setAnchorRect(null); } }, [isOpen]); const value = useMemo( () => ({ open: isOpen, setOpen: setIsOpen, triggerRef, anchorRect, setAnchorRect, }), [anchorRect, isOpen, setAnchorRect, setIsOpen], ); return {children}; } type TriggerState = { pressed: boolean; hovered: boolean; open: boolean }; type TriggerStyleProp = StyleProp | ((state: TriggerState) => StyleProp); export function ContextMenuTrigger({ children, disabled, style, enabled = true, enabledOnMobile = false, enabledOnWeb = true, longPressDelayMs, triggerRef, ...props }: PropsWithChildren< Omit & { style?: TriggerStyleProp; enabled?: boolean; enabledOnMobile?: boolean; enabledOnWeb?: boolean; longPressDelayMs?: number; triggerRef?: Ref; } >): ReactElement { const ctx = useContextMenuContext("ContextMenuTrigger"); const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile); const openAtEvent = useCallback( (event: unknown) => { if (!shouldEnableOnThisPlatform || disabled) { return; } const point = coerceEventPoint(event); if (!point) { return; } const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; ctx.setAnchorRect({ x: point.pageX, y: point.pageY + statusBarHeight, width: 0, height: 0, }); ctx.setOpen(true); }, [ctx, disabled, shouldEnableOnThisPlatform], ); const handleRef = useCallback( (node: View | null) => { assignRef(ctx.triggerRef, node); assignRef(triggerRef, node); }, [ctx.triggerRef, triggerRef], ); return ( { if (isWeb) { props.onLongPress?.(event); return; } openAtEvent(event); props.onLongPress?.(event); }} // @ts-ignore - onContextMenu is web-only and not in RN types. onContextMenu={(event: unknown) => { if (isNative) { return; } const e: any = event; e?.preventDefault?.(); e?.stopPropagation?.(); openAtEvent(event); }} style={({ pressed, hovered = false }) => { if (typeof style === "function") { return style({ pressed, hovered: Boolean(hovered), open: ctx.open }); } return style; }} > {children} ); } export function ContextMenuContent({ children, side = "bottom", align = "start", offset = 4, width, minWidth = 180, maxWidth, fullWidth = false, horizontalPadding = 16, mobileMode = "dropdown", testID, }: PropsWithChildren<{ side?: Placement; align?: Alignment; offset?: number; width?: number; minWidth?: number; maxWidth?: number; fullWidth?: boolean; horizontalPadding?: number; mobileMode?: MobileMenuMode; testID?: string; }>): ReactElement | null { const context = useContextMenuContext("ContextMenuContent"); const { theme } = useUnistyles(); const webScrollbarStyle = useWebScrollbarStyle(); const isMobile = useIsCompactFormFactor(); const useMobileSheet = isMobile && mobileMode === "sheet"; const { open, setOpen, triggerRef, anchorRect } = context; const bottomSheetRef = useRef(null); const sheetSnapPoints = useMemo(() => ["30%", "55%"], []); const [triggerRect, setTriggerRect] = useState(null); const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null); const [position, setPosition] = useState<{ x: number; y: number } | null>(null); const handleClose = useCallback(() => { setOpen(false); }, [setOpen]); useEffect(() => { if (!useMobileSheet) return; if (open) { bottomSheetRef.current?.present(); return; } bottomSheetRef.current?.dismiss(); }, [open, useMobileSheet]); const handleSheetChange = useCallback( (index: number) => { if (index === -1) { handleClose(); } }, [handleClose], ); const renderSheetBackdrop = useCallback( (props: ComponentProps) => ( ), [], ); // Measure trigger when opening (fallback) and capture point anchors. useEffect(() => { if (useMobileSheet) { setTriggerRect(null); setContentSize(null); setPosition(null); return; } if (!open) { setTriggerRect(null); setContentSize(null); setPosition(null); return; } if (anchorRect) { setTriggerRect(anchorRect); return; } if (!triggerRef.current) { setTriggerRect(null); return; } const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0; let cancelled = false; measureElement(triggerRef.current).then((rect) => { if (cancelled) return; setTriggerRect({ ...rect, y: rect.y + statusBarHeight, }); }); return () => { cancelled = true; }; }, [anchorRect, open, triggerRef, useMobileSheet]); // Calculate position when we have both measurements useEffect(() => { if (useMobileSheet) return; if (!triggerRect || !contentSize) return; const { width: screenWidth, height: screenHeight } = Dimensions.get("window"); const displayArea = { x: 0, y: 0, width: screenWidth, height: screenHeight, }; const result = computePosition({ triggerRect, contentSize, displayArea, placement: side, alignment: align, offset, }); const x = fullWidth ? horizontalPadding : result.x; setPosition({ x, y: result.y }); }, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding, useMobileSheet]); const handleContentLayout = useCallback( (event: { nativeEvent: { layout: { width: number; height: number } } }) => { const { width: w, height: h } = event.nativeEvent.layout; setContentSize({ width: w, height: h }); }, [], ); if (useMobileSheet) { return ( {children} ); } if (!open) return null; const { width: screenWidth } = Dimensions.get("window"); const resolvedWidthStyle: ViewStyle = fullWidth ? { width: screenWidth - horizontalPadding * 2 } : { ...(typeof width === "number" ? { width } : null), ...(typeof minWidth === "number" ? { minWidth } : null), ...(typeof maxWidth === "number" ? { maxWidth } : null), }; return ( {children} ); } export function ContextMenuLabel({ children, style, testID, }: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement { return ( {children} ); } export function ContextMenuSeparator({ style, testID, }: { style?: ViewStyle; testID?: string; }): ReactElement { return ; } export function ContextMenuHint({ children, testID, }: PropsWithChildren<{ testID?: string }>): ReactElement { return ( {children} ); } export function ContextMenuItem({ children, description, onSelect, disabled, destructive, selected, showSelectedCheck = false, selectedVariant = "default", leading, trailing, loading, status, pendingLabel, successLabel, closeOnSelect = true, testID, tooltip, }: PropsWithChildren<{ description?: string; onSelect?: () => void; disabled?: boolean; destructive?: boolean; selected?: boolean; showSelectedCheck?: boolean; selectedVariant?: "default" | "accent"; leading?: ReactElement | null; trailing?: ReactElement | null; /** @deprecated Use `status` instead */ loading?: boolean; status?: ActionStatus; pendingLabel?: string; successLabel?: string; closeOnSelect?: boolean; testID?: string; tooltip?: string; }>): ReactElement { const { theme } = useUnistyles(); const { setOpen } = useContextMenuContext("ContextMenuItem"); const isPending = status === "pending" || loading; const isSuccess = status === "success"; const isDisabled = disabled || isPending || isSuccess; let leadingContent: ReactElement | null = null; if (isPending) { leadingContent = ; } else if (isSuccess) { leadingContent = ; } else if (leading) { leadingContent = leading; } let label = children; if (isPending && pendingLabel) { label = pendingLabel; } else if (isSuccess && successLabel) { label = successLabel; } const trailingContent = trailing ?? (!showSelectedCheck && selected ? ( ) : null); const content = ( { if (isDisabled) return; if (closeOnSelect) { setOpen(false); } onSelect?.(); }} style={({ pressed, hovered }) => [ styles.item, selected ? selectedVariant === "accent" ? styles.itemSelectedAccent : styles.itemSelected : null, selected && (hovered || pressed) && selectedVariant !== "accent" ? styles.itemSelectedInteractive : null, isDisabled ? styles.itemDisabled : null, hovered && !pressed && !isDisabled ? styles.itemHovered : null, pressed && !isDisabled ? styles.itemPressed : null, ]} > {showSelectedCheck ? ( {selected ? : null} ) : null} {leadingContent ? {leadingContent} : null} {label} {description && !isPending && !isSuccess ? ( {description} ) : null} {trailingContent ? {trailingContent} : null} ); if (!tooltip) { return content; } return ( {content} {tooltip} ); } const styles = StyleSheet.create((theme) => ({ overlay: { flex: 1, }, backdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, }, content: { backgroundColor: theme.colors.surface0, borderWidth: 1, borderColor: theme.colors.border, borderRadius: theme.borderRadius.lg, ...theme.shadow.md, overflow: "hidden", }, sheetBackground: { backgroundColor: theme.colors.surface0, borderTopLeftRadius: theme.borderRadius.xl, borderTopRightRadius: theme.borderRadius.xl, borderWidth: 1, borderColor: theme.colors.border, }, sheetHandle: { backgroundColor: theme.colors.surface2, }, sheetScrollContent: { paddingTop: theme.spacing[1], paddingBottom: theme.spacing[4], }, labelContainer: { paddingHorizontal: theme.spacing[3], paddingTop: theme.spacing[2], paddingBottom: theme.spacing[1], }, labelText: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, textTransform: "uppercase", letterSpacing: 0.6, }, separator: { height: 1, backgroundColor: theme.colors.border, }, hintContainer: { paddingHorizontal: theme.spacing[3], paddingBottom: theme.spacing[2], }, hintText: { fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, tooltipText: { fontSize: theme.fontSize.sm, color: theme.colors.foreground, }, item: { flexDirection: "row", alignItems: "center", minHeight: 36, gap: theme.spacing[2], paddingHorizontal: theme.spacing[3], paddingVertical: theme.spacing[2], borderWidth: theme.borderWidth[1], borderColor: "transparent", }, itemHovered: { backgroundColor: theme.colors.surface1, }, itemPressed: { backgroundColor: theme.colors.surface1, }, itemDisabled: { opacity: 0.5, }, itemSelected: { backgroundColor: theme.colors.surface1, }, itemSelectedInteractive: { backgroundColor: theme.colors.surface1, }, itemSelectedAccent: { backgroundColor: theme.colors.accent, }, checkSlot: { width: 16, alignItems: "center", justifyContent: "center", }, leadingSlot: { width: 16, alignItems: "center", justifyContent: "center", }, trailingSlot: { marginLeft: "auto", alignItems: "center", justifyContent: "center", }, itemContent: { flexShrink: 1, }, itemText: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.normal, color: theme.colors.foreground, }, itemTextDestructive: { color: theme.colors.destructive, }, itemTextSuccess: { color: theme.colors.palette.green[500], }, itemTextSelectedAccent: { color: theme.colors.accentForeground, }, itemDescription: { marginTop: 2, fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, }, itemDescriptionSelectedAccent: { color: theme.colors.accentForeground, opacity: 0.85, }, }));