import { createContext, type ReactNode, useCallback, useContext, useMemo, useRef, useState } from 'react'; import { Dimensions, type LayoutChangeEvent, Modal, Pressable, type StyleProp, View, type ViewStyle } from 'react-native'; import { useModalRender } from '../../../hooks/use-modal-render'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { cn } from '../../../lib/cn'; import type { SurfaceElevation } from '../../../lib/elevated'; import { MotiView } from '../../../moti/components/view'; import { AnimatePresence } from '../../../moti/presence/animate-presence'; import { type MenuMotion, menuTransformOrigin, resolveMenuMotion } from '../../../theme/motion'; import { Surface } from '../../display/Surface/surface'; import { Text } from '../../typography/Text/text'; import { OverlayBlur } from '../Overlay/overlay-blur'; import { OverlayOutlet } from '../Overlay/overlay-portal'; import type { OverlayType } from '../Overlay/overlay-type'; export type PopoverSide = 'top' | 'bottom'; export type PopoverAlign = 'start' | 'center' | 'end'; // biome-ignore lint/style/useExportsLast: type collocated with sibling Popover type exports for readability export type PopoverTriggerMode = 'click' | 'hover'; type Rect = { x: number; y: number; w: number; h: number }; type PopoverContext = { open: boolean; setOpen: (v: boolean) => void; toggle: () => void; rect: Rect | null; setRect: (r: Rect) => void; side: PopoverSide; align: PopoverAlign; gap: number; panelRadius: number; motion?: MenuMotion; reduce: boolean; overlay: OverlayType; closeOnOutsidePress: boolean; }; const Ctx = createContext(null); function usePopover(component: string) { const ctx = useContext(Ctx); if (!ctx) throw new Error(`${component} must be used within `); return ctx; } export type PopoverProps = { children: ReactNode; /** Controlled open state. */ open?: boolean; /** Uncontrolled initial open state. */ defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; /** Kept for web parity; on RN both modes tap-to-toggle (no hover on touch). */ trigger?: PopoverTriggerMode; /** Which side of the trigger the panel opens from. Default "bottom". */ side?: PopoverSide; /** Alignment along the trigger's edge. Default "center". */ align?: PopoverAlign; /** Gap between trigger and panel, in px. Default 14. */ sideOffset?: number; /** Corner radius of the panel, in px. Default 16. */ panelRadius?: number; /** * Overrides the shared open/close animation — the same `motion` prop * `AdaptiveDropdown` and `HoverMenu` take. Reduced motion * overrides all of it: the panel cross-fades in place. * * @example * motion={{ enter: { stiffness: 420 }, scale: 0.9 }} */ motion?: MenuMotion; /** No-op on RN (drove the web goo blur). Kept for API parity. */ gooStrength?: number; style?: StyleProp; testID?: string; /** The scrim behind the panel: `"blur"`, `"opacity"`, or `"none"`. Defaults to `"none"` — a popover floats over the page untouched. */ overlay?: OverlayType; /** When false, pressing outside the panel will not close it. Defaults to true. */ closeOnOutsidePress?: boolean; }; export function Popover({ children, open: controlledOpen, defaultOpen = false, onOpenChange, side = 'bottom', align = 'center', sideOffset = 14, panelRadius = 16, motion, style, testID, overlay = 'none', closeOnOutsidePress = true, }: PopoverProps) { const reduce = useReducedMotion(); const [internalOpen, setInternalOpen] = useState(defaultOpen); const [rect, setRect] = useState(null); const controlled = controlledOpen !== undefined; const open = controlled ? controlledOpen : internalOpen; const setOpen = useCallback( (next: boolean) => { if (!controlled) setInternalOpen(next); onOpenChange?.(next); }, [controlled, onOpenChange], ); const toggle = useCallback(() => setOpen(!open), [setOpen, open]); const ctx = useMemo( () => ({ open, setOpen, toggle, rect, setRect, side, align, gap: sideOffset, panelRadius, motion, reduce, overlay, closeOnOutsidePress, }), [open, setOpen, toggle, rect, side, align, sideOffset, panelRadius, motion, reduce, overlay, closeOnOutsidePress], ); return ( {children} ); } export type PopoverTriggerProps = { children: ReactNode; accessibilityLabel?: string; /** Merged onto the Pressable — overrides the default trigger look. */ className?: string; style?: StyleProp; testID?: string; }; export function PopoverTrigger({ children, accessibilityLabel, className, style, testID }: PopoverTriggerProps) { const { toggle, setRect, open } = usePopover('PopoverTrigger'); const ref = useRef(null); // A plain-text trigger is the button this wrapper becomes. A custom-node // trigger already announces its own control (the wrapper only measures the // frame and toggles) — claiming `role="button"` here would nest a