/** * Popover — a panel anchored to the thing that opened it. * * A dialog takes the screen and asks to be dealt with; a popover stays next to * its trigger and keeps the context around it visible. That difference is the * whole reason both exist, and it is why this one is positioned rather than * centred. * * ```tsx * * * * * * Export * Choose a format. * * * ``` * * Placement is a preference, not a promise. The trigger is measured in window * coordinates when it is pressed, the panel measures itself on its first * layout, and the two are reconciled against the safe area: a panel that would * run off the bottom flips above the trigger, and one that would run off the * side slides back inside. So `placement="bottom"` means *below, if below * fits* — which is the only behaviour that survives a trigger near an edge. * * The first frame is rendered transparent, because the panel's own size is not * known until it has laid out once. Without that it would appear at the origin * and jump into place. */ import { Children, cloneElement, createContext, isValidElement, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode, } from 'react'; import { Pressable, ScrollView, useWindowDimensions, View, type LayoutChangeEvent, type ViewProps, } from 'react-native'; import Animated, { FadeOut, useAnimatedStyle, useReducedMotion, useSharedValue, withSpring, withTiming, } from 'react-native-reanimated'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { FocusRestorePortal } from '../../primitives/portal'; import { Scrim } from '../../primitives/scrim'; import { useBackHandler } from '../../hooks/use-back-handler'; import { NativeHost, getSwiftUI } from '../../native'; import { BottomSheet } from '../bottom-sheet'; import { Text, type TextProps, textChildren } from '../../primitives/text'; import { cn } from '../../utils/cn'; /** Gap between the trigger and the panel. */ const DEFAULT_OFFSET = 8; /** Smallest gap allowed between the panel and the edge of the safe area. */ const SCREEN_MARGIN = 12; /** Side of the arrow square before it is rotated 45°. */ const ARROW_SIZE = 12; /** * Headroom the sheet presentation leaves for the close button. * * That button floats over the sheet's top-end corner rather than sitting in the * flow, so nothing below it is pushed out of its way. The sheet's own padding * and grabber put the first child 24 points down; the button's lower edge is at * 44. This is the difference, plus a little air — enough that a panel's first * row clears the button instead of being drawn under it. */ const SHEET_CLOSE_CLEARANCE = 24; export type PopoverPlacement = 'top' | 'bottom' | 'left' | 'right'; export type PopoverAlign = 'start' | 'center' | 'end'; /** * The rectangle the panel is placed against, in window coordinates. * * Normally the trigger's own bounds. A zero-sized rect is meaningful too: it * anchors the panel to a single point, which is what a menu opened by a long * press on arbitrary content needs — there the interesting position is where * the finger landed, not the bounds of whatever it landed on. */ export interface PopoverAnchorRect { x: number; y: number; width: number; height: number; } type TriggerRect = PopoverAnchorRect; interface PopoverContextValue { open: boolean; setOpen: (open: boolean) => void; trigger: TriggerRect | null; setTrigger: (rect: TriggerRect | null) => void; /** Resolved placement, published by Content so Arrow knows which way to point. */ placement: PopoverPlacement; setPlacement: (placement: PopoverPlacement) => void; /** Trigger centre along the cross axis, relative to the panel origin. */ arrowOffset: number; setArrowOffset: (offset: number) => void; /** Whether Content is presenting as a bottom sheet — Arrow is null then. */ presentation: PopoverPresentation; /** Whether the platform is drawing the panel. Trigger and Content both read it. */ native: boolean; } export type PopoverPresentation = 'popover' | 'bottom-sheet'; const PopoverContext = createContext(null); function usePopover(component: string): PopoverContextValue { const context = useContext(PopoverContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } export interface PopoverAnchorControls { open: boolean; setOpen: (open: boolean) => void; /** * Place the panel against an explicit rect rather than against a measured * trigger. Pass a zero-sized rect to anchor it to a point. */ anchorTo: (rect: PopoverAnchorRect) => void; } /** * For a trigger that opens the panel on something other than a plain press, or * anchors it to something other than its own bounds. * * `Popover.Trigger` covers the ordinary case — press the thing, measure the * thing, open next to it. A component built on this one may need neither half * of that: a context menu opens on a long press and belongs at the point the * finger landed. Rather than have it own a second copy of the placing, * flipping and edge-clamping this file already does, it borrows them by * setting the anchor itself. * * Only useful inside a `Popover`, which is the same rule every other part here * follows. */ export function usePopoverAnchor(component: string): PopoverAnchorControls { const { open, setOpen, setTrigger } = usePopover(component); return useMemo( () => ({ open, setOpen, anchorTo: setTrigger }), [open, setOpen, setTrigger] ); } export interface PopoverProps { children: ReactNode; /** Controlled open state. */ open?: boolean; onOpenChange?: (open: boolean) => void; /** Initial state when uncontrolled. */ defaultOpen?: boolean; /** * `popover` is the anchored panel. `bottom-sheet` presents the content in a * draggable sheet instead — better on a small screen, or when the content is * a form rather than a menu. Placement, align and the arrow do not apply to a * sheet. */ presentation?: PopoverPresentation; /** * Present the platform's own popover instead of this one. Requires the * optional `@expo/ui`. * * **iOS only.** SwiftUI has a popover that anchors to a view and keeps its * anchored shape on a phone rather than becoming a sheet; Compose's nearest * relative is a dropdown menu, which is a different control with different * rules. Android and web keep the styled panel, as does an iOS device * without `@expo/ui` installed. * * **The platform draws the container, so theme tokens do not reach it.** The * panel's surface, its corner radius, its shadow and its arrow are the * system's; `className` on `Popover.Content` styles what is *inside* it. * `align`, `offset`, `alignOffset`, `scrim` and `blur` have no native * equivalent and are ignored; `placement` becomes the edge the arrow is * asked for. * * **Give the content a `width`.** The platform sizes its popover to what is * hosted in it, and a React Native subtree with no width of its own has * nothing to report — the same rule that governs every hosted view. * `Popover.Content` defaults to a sensible one under `native`, but a panel * whose rows need more room should say so. */ native?: boolean; } function PopoverRoot({ children, open, onOpenChange, defaultOpen = false, presentation = 'popover', native = false, }: PopoverProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const [trigger, setTrigger] = useState(null); const [placement, setPlacement] = useState('bottom'); const [arrowOffset, setArrowOffset] = useState(0); const isControlled = open !== undefined; const resolvedOpen = isControlled ? open : internalOpen; const setOpen = useCallback( (next: boolean) => { if (!isControlled) setInternalOpen(next); onOpenChange?.(next); }, [isControlled, onOpenChange] ); /* * The platform's popover, where it is reachable and the caller asked for it. * * A sheet presentation is left alone: `native` names which popover to draw, * and asking for both a sheet and a popover is a contradiction rather than a * combination. */ const swiftUI = native && presentation === 'popover' ? getSwiftUI() : null; const nativeActive = swiftUI !== null; const context = useMemo( () => ({ open: resolvedOpen, setOpen, trigger, setTrigger, placement, setPlacement, arrowOffset, setArrowOffset, presentation, native: nativeActive, }), [resolvedOpen, setOpen, trigger, placement, arrowOffset, presentation, nativeActive] ); return ( {swiftUI ? {children} : children} ); } /** Default width for a hosted panel, in points. Room for a short menu row. */ const NATIVE_PANEL_WIDTH = 240; /** `placement` in the platform's vocabulary. */ const ARROW_EDGE = { top: 'top', bottom: 'bottom', left: 'leading', right: 'trailing', } as const; /** * The platform's popover, with our trigger and our content hosted inside it. * * SwiftUI attaches a popover to a view, so the two halves this component keeps * as siblings have to become parent and child: the trigger is what the panel * points at, and the platform will not anchor to something it cannot see. So * the children are read here and placed into the two slots the platform * expects, rather than the caller having to write a different tree under * `native` than without it. * * Both halves are React Native, so both are wrapped in the host view that lets * the platform measure them. The trigger is given no press handling of its * own — `Popover.Trigger` already toggles the state this reads, and the * platform presents from that. */ function NativePopover({ swiftUI, children, }: { swiftUI: NonNullable>; children: ReactNode; }) { const { open, setOpen } = usePopover('Popover'); const { Host, RNHostView, Popover: PlatformPopover } = swiftUI; let trigger: ReactNode = null; let content: ReactElement | null = null; for (const child of Children.toArray(children)) { if (!isValidElement(child)) continue; if (child.type === PopoverTrigger) trigger = child; else if (child.type === PopoverContent) { content = child as ReactElement; } } const contentProps = content?.props; const width = typeof contentProps?.width === 'number' ? contentProps.width : NATIVE_PANEL_WIDTH; return ( {trigger} {/* An explicit width, not a class. Inside the host there is no parent for a percentage or a flex basis to resolve against, so a panel that does not state its width reports none and the platform sizes its popover to nothing. */} {textChildren(contentProps?.children)} ); } export interface PopoverTriggerProps { children: ReactElement<{ onPress?: (...args: unknown[]) => void }>; } /** * Wraps its child and toggles the popover on press. * * The child is wrapped in a view rather than given a ref directly: the ref has * to survive whatever the child is — a Button, a plain Pressable, an icon — * and only a wrapper we own is guaranteed to be measurable. */ function PopoverTrigger({ children }: PopoverTriggerProps) { const { open, setOpen, setTrigger, native } = usePopover('Popover.Trigger'); const ref = useRef(null); const measureThenToggle = (...args: unknown[]) => { if (isValidElement(children)) children.props.onPress?.(...args); if (open) { setOpen(false); return; } /* * Nothing to measure when the platform is drawing the panel: it anchors to * this trigger itself, and asking a view hosted inside the native tree for * its window coordinates is a callback that may never come back — which * would leave the press doing nothing at all. */ if (native) { setOpen(true); return; } // Measured on every open rather than on layout: the trigger may have // scrolled since it was laid out, and a stale rect anchors the panel to // where the trigger used to be. ref.current?.measureInWindow((x, y, width, height) => { setTrigger({ x, y, width, height }); setOpen(true); }); }; return ( {isValidElement(children) ? cloneElement(children, { onPress: measureThenToggle }) : children} ); } export interface PopoverContentProps extends ViewProps { className?: string; /** Preferred side of the trigger. Flipped when that side does not fit. */ placement?: PopoverPlacement; /** Where the panel sits along the trigger's other axis. */ align?: PopoverAlign; /** Gap between the trigger and the panel, in pixels. */ offset?: number; /** Nudge along the alignment axis, in pixels. */ alignOffset?: number; /** * `content-fit` sizes to the content, `trigger` matches the trigger's width, * `full` spans the safe area, and a number is that many pixels. */ width?: number | 'trigger' | 'full' | 'content-fit'; /** * Floor for the panel's width, in pixels. Worth setting with * `width="trigger"`, where a narrow trigger would otherwise squeeze the * content into a column. */ minWidth?: number; /** * Ceiling for the panel's height, in pixels. Always clamped to the room * inside the safe area, which is also the default — a panel is never * positioned so that part of it falls off the screen, because the part that * falls off cannot be scrolled back into view. */ maxHeight?: number; /** * Scroll the panel's body when it is taller than `maxHeight`. * * Off by default, because a popover is usually a paragraph or a short form * and a scroller around either one only adds a bounce. Worth turning on for * a list of unknown length, which is the case where the cap actually bites. * * The spacing between children moves to the scroller's content when this is * set; `className` still dresses the panel itself. */ scrollable?: boolean; /** * Drop the panel's own surface — its background, border, radius, padding and * shadow — and keep only its position and its size. For a caller that draws * the surface itself, so that something can be put *behind* the content * rather than layered on top of a background that is already painted. * * The panel is still clipped to a rounded rectangle, because a surface drawn * inside it has to have something to be clipped by. */ unstyled?: boolean; /** * A layer drawn inside the panel, behind its content — and, crucially, * outside its scroller, so that a surface does not scroll away with the rows * on top of it. Pair it with `unstyled` to own the panel's appearance. */ background?: ReactNode; /** Tap outside the panel closes it. Default true. */ dismissible?: boolean; /** * Frost the background behind the panel instead of dimming it. Uses * `expo-blur` when installed and falls back to the dimmed scrim when it is * not, so it is safe to pass either way. * * Someone who has Reduce Transparency switched on gets an opaque * backdrop instead, which is the whole point of the setting. */ blur?: boolean; /** * Dim the screen behind the panel. * * Off by default: a popover is a panel *beside* something, and dimming the * page says the thing behind it has stopped being available — which is a * dialog's claim, not a popover's. Worth turning on when the panel is the * only thing that matters while it is up, which is what a menu opened on the * content itself is. Ignored under `blur`, which draws its own dim. */ scrim?: boolean; /** The dim's classes, when `scrim` is set. */ scrimClassName?: string; children?: ReactNode; } function PopoverContent({ className, placement = 'bottom', align = 'center', offset = DEFAULT_OFFSET, alignOffset = 0, width = 'content-fit', minWidth, maxHeight, scrollable = false, unstyled = false, background, dismissible = true, blur = false, scrim = false, scrimClassName = 'bg-black/30', children, onLayout: onLayoutProp, style, ...props }: PopoverContentProps) { const context = usePopover('Popover.Content'); const { open, setOpen, trigger, setPlacement, setArrowOffset, presentation, native } = context; // The anchored panel owns the Android back button while it is up. The sheet // presentation is left alone — BottomSheet installs its own handler. useBackHandler(open && dismissible && presentation !== 'bottom-sheet', () => setOpen(false) ); const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const insets = useSafeAreaInsets(); const [size, setSize] = useState<{ width: number; height: number } | null>(null); // Panel size changes with its content, so it is re-measured rather than // measured once — a popover whose body grows should not stay the old size. const onLayout = (event: LayoutChangeEvent) => { const { width: w, height: h } = event.nativeEvent.layout; setSize((current) => current && Math.abs(current.width - w) < 1 && Math.abs(current.height - h) < 1 ? current : { width: w, height: h } ); onLayoutProp?.(event); }; const bounds = { left: insets.left + SCREEN_MARGIN, right: screenWidth - insets.right - SCREEN_MARGIN, top: insets.top + SCREEN_MARGIN, bottom: screenHeight - insets.bottom - SCREEN_MARGIN, }; const available = bounds.right - bounds.left; const requestedWidth = width === 'content-fit' ? undefined : width === 'trigger' ? trigger?.width : width === 'full' ? available : width; // The floor never wins past the space there actually is — a panel wider than // the screen is worse than a cramped one. const resolvedWidth = minWidth !== undefined && requestedWidth !== undefined ? Math.min(Math.max(requestedWidth, minWidth), available) : requestedWidth; /* * The floor also has to reach a panel sized to its own contents, which is the * case it matters most in — a panel with a width already knows how wide it is. * * Folding it into `width` would be wrong: that pins the panel open at exactly * the floor and stops it growing for content that needs more. It is a real * minimum instead, left off when there is nothing to enforce so a content-fit * panel keeps shrinking to fit. * * Without this a `content-fit` panel takes its width from whatever inside it * is *not* flexible. A row of a flexible label and a fixed glyph collapses to * the glyph, and the panel comes up as a strip of icons with the words * squeezed out of it. */ const resolvedMinWidth = minWidth === undefined ? undefined : Math.min(minWidth, available); /* * The cap is what keeps a tall panel reachable. `place` clamps the panel * inside the bounds, but a panel taller than the bounds cannot be clamped * into them — it gets pinned to the top edge and the rest runs off the * bottom of the screen, where there is no way to get at it. Capping the * height first means the clamp always has a solution, and `scrollable` * hands the overflow back to the finger. */ const room = bounds.bottom - bounds.top; const resolvedMaxHeight = maxHeight === undefined ? room : Math.min(maxHeight, room); const position = trigger && size ? place({ trigger, size, placement, align, offset, alignOffset, bounds }) : null; // Publish the side actually used and where the trigger centre landed, so the // arrow points at the trigger even after a flip or a clamp. const resolvedPlacement = position?.placement; const resolvedArrow = position?.arrowOffset; useEffect(() => { if (resolvedPlacement) setPlacement(resolvedPlacement); if (resolvedArrow !== undefined) setArrowOffset(resolvedArrow); }, [resolvedPlacement, resolvedArrow, setPlacement, setArrowOffset]); /* * The entrance is driven by hand rather than by an `entering` preset, and the * reason is the measuring frame. A layout animation fires on mount — which * here is the frame *before* the panel knows where it goes, so the whole * animation would play at the origin, invisibly, and the panel would then * snap into place fully formed. Holding the values until a position exists * is the only way to have the animation and the correct position both. */ const appear = useSharedValue(0); const settle = useSharedValue(0); const reducedMotion = useReducedMotion(); const placed = !!position; useEffect(() => { if (!placed) { appear.value = 0; settle.value = 0; return; } if (reducedMotion) { appear.value = 1; settle.value = 1; return; } appear.value = withTiming(1, { duration: 120 }); settle.value = withSpring(1, { damping: 18, stiffness: 250, mass: 0.6 }); }, [placed, reducedMotion, appear, settle]); // Starts slightly small and shifted towards the trigger, so the panel // appears to unfold from it rather than fade in over it. const origin = ENTRY_SHIFT[resolvedPlacement ?? placement]; const panelStyle = useAnimatedStyle(() => ({ opacity: appear.value, transform: [ { translateX: origin.x * (1 - settle.value) }, { translateY: origin.y * (1 - settle.value) }, { scale: 0.94 + 0.06 * settle.value }, ], })); /* * The sheet presentation hands off entirely to BottomSheet — it owns its own * portal, backdrop and dismiss gesture, so there is nothing to anchor here. * The context is re-provided inside so Popover.Close keeps closing it. * * `width` still means something, though. A sheet is the full width of the * screen and lays its children out in a column, so a panel sized to its * content takes the cross-axis *start* and ends up flush against one edge — * the right-hand one under RTL, directly below the close button. Centring it * is what makes `width="content-fit"` mean the same thing in both * presentations. * * The className goes on an inner view rather than on the sheet: passed to * the surface it is merged into the sheet's own padding classes and replaces * them, so a panel asking for `p-3` silently strips the sheet's `px-5 pt-2`. */ /* * Already drawn. Under `native` the root reads this element's props and * hosts its children inside the platform's own popover, so rendering the * styled panel here as well would put a second one on the screen. */ if (native) return null; if (presentation === 'bottom-sheet') { return ( {textChildren(children)} ); } if (!open || !trigger) return null; return ( {/* Portal content mounts under PortalHost, outside this provider's subtree — re-provide the context so Popover.Close and Popover.Arrow keep working. */} {/* A popover does not dim the screen by default — the backdrop is there only to catch the outside tap. `blur` opts into a frost, `scrim` into a plain dim; the frost already draws one of its own, so asking for both is not two dims. */} {blur ? ( ) : scrim ? ( ) : null} setOpen(false) : undefined} /> {/* Two views, not one, and the reason is a Reanimated rule: a layout animation and an animated style may not drive the same property on the same component, or the layout animation silently wins. The exit fade and `panelStyle`'s opacity both want it — so the outer view owns the position and the exit, and the inner one owns the entrance and the panel's own surface. */} {background} {scrollable ? ( {textChildren(children)} ) : ( textChildren(children) )} ); } export interface PopoverArrowProps extends ViewProps { className?: string; } /** * A small square rotated into a diamond, half-buried under the panel so only * the point shows. It needs the panel to have a border for its own two visible * edges to line up with — without one it reads as a floating lozenge. * * It points at the trigger's centre, which the panel resolves and publishes: * when `align` shifts the panel off-centre, or a clamp slides it back on * screen, the arrow stays over the trigger rather than over the panel's middle. */ function PopoverArrow({ className, style, ...props }: PopoverArrowProps) { const { trigger, placement, arrowOffset, presentation } = usePopover('Popover.Arrow'); // A sheet has no trigger edge to point at. if (!trigger || presentation === 'bottom-sheet') return null; const vertical = placement === 'top' || placement === 'bottom'; // The arrow sits on the edge facing the trigger, which is the opposite edge // to the placement: a panel placed below has its arrow on top. const edge = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }[placement]; return ( ); } const PopoverTitle = ({ className, ...props }: TextProps) => ( ); PopoverTitle.displayName = 'Popover.Title'; const PopoverDescription = ({ className, ...props }: TextProps) => ( ); PopoverDescription.displayName = 'Popover.Description'; export interface PopoverCloseProps { children: ReactElement<{ onPress?: (...args: unknown[]) => void }>; } /** Wraps its child and closes the popover on press. */ function PopoverClose({ children }: PopoverCloseProps) { const { setOpen } = usePopover('Popover.Close'); if (!isValidElement(children)) return children; return cloneElement(children, { onPress: (...args: unknown[]) => { children.props.onPress?.(...args); setOpen(false); }, }); } /* -------------------------------------------------------------------------- */ /* Placement */ /* -------------------------------------------------------------------------- */ interface PlaceArgs { trigger: TriggerRect; size: { width: number; height: number }; placement: PopoverPlacement; align: PopoverAlign; offset: number; alignOffset: number; bounds: { left: number; right: number; top: number; bottom: number }; } /** * Resolves the panel's window position. * * Two passes, in this order, because they answer different questions: the flip * picks a *side* and only fires when the preferred one genuinely has less room * than its opposite; the clamp then slides the panel along the other axis to * keep it on screen. Doing the clamp first would let a panel be nudged inside * the bounds and so look like it fits, hiding the fact that the wrong side was * chosen. */ function place({ trigger, size, placement, align, offset, alignOffset, bounds }: PlaceArgs) { const roomAfter = { bottom: bounds.bottom - (trigger.y + trigger.height + offset), top: trigger.y - offset - bounds.top, right: bounds.right - (trigger.x + trigger.width + offset), left: trigger.x - offset - bounds.left, }; const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' } as const; const needed = placement === 'top' || placement === 'bottom' ? size.height : size.width; const resolved = roomAfter[placement] < needed && roomAfter[opposite[placement]] > roomAfter[placement] ? opposite[placement] : placement; const clamp = (value: number, min: number, max: number) => // `max < min` when the panel is wider than the space it has; pinning to the // start edge at least keeps its beginning readable. Math.max(min, Math.min(value, Math.max(min, max))); // The arrow points at the trigger's centre, not the panel's — those differ // whenever `align` is not center, or the panel was clamped inside the screen. // It is expressed relative to the panel origin and clamped so it can never // slide off the panel edge. if (resolved === 'top' || resolved === 'bottom') { const top = resolved === 'bottom' ? trigger.y + trigger.height + offset : trigger.y - size.height - offset; const left = align === 'start' ? trigger.x + alignOffset : align === 'end' ? trigger.x + trigger.width - size.width + alignOffset : trigger.x + trigger.width / 2 - size.width / 2 + alignOffset; const clampedLeft = clamp(left, bounds.left, bounds.right - size.width); const triggerCentreX = trigger.x + trigger.width / 2; return { placement: resolved, top: clamp(top, bounds.top, bounds.bottom - size.height), left: clampedLeft, arrowOffset: clamp(triggerCentreX - clampedLeft, ARROW_SIZE, size.width - ARROW_SIZE), }; } const left = resolved === 'right' ? trigger.x + trigger.width + offset : trigger.x - size.width - offset; const top = align === 'start' ? trigger.y + alignOffset : align === 'end' ? trigger.y + trigger.height - size.height + alignOffset : trigger.y + trigger.height / 2 - size.height / 2 + alignOffset; const clampedTop = clamp(top, bounds.top, bounds.bottom - size.height); const triggerCentreY = trigger.y + trigger.height / 2; return { placement: resolved, top: clampedTop, left: clamp(left, bounds.left, bounds.right - size.width), arrowOffset: clamp(triggerCentreY - clampedTop, ARROW_SIZE, size.height - ARROW_SIZE), }; } /** * Where the panel starts, relative to where it ends: towards the trigger, on * whichever side was resolved. A preset like `ZoomIn` always grows from the * centre, which reads the same whichever way the panel opened. */ const ENTRY_SHIFT: Record = { bottom: { x: 0, y: -8 }, top: { x: 0, y: 8 }, right: { x: -8, y: 0 }, left: { x: 8, y: 0 }, }; export const Popover = Object.assign(PopoverRoot, { Trigger: PopoverTrigger, Content: PopoverContent, Arrow: PopoverArrow, Title: PopoverTitle, Description: PopoverDescription, Close: PopoverClose, });