import type { ReactNode, RefObject } from "react"; import { Modal, Pressable, type View as RNView } from "react-native"; import { useReducedMotion } from "../../core/ReducedMotionContext"; import { SafeAreaProvider } from "../../core/SafeAreaProvider"; import { useSafeAreaInsets } from "../../core/useSafeAreaInsets"; import { View } from "../primitives/View"; import type { PortalAccentScopeProps } from "./PortalAccentScope"; import { PortalAccentScope } from "./PortalAccentScope"; // Gap between the status bar / notch and the first row of a `top` popover. The // inset itself is a device value read at runtime, so it cannot be a `pt-*`. const statusBarGap = 8; // spacing-xs export interface PopoverProps { open: boolean; onClose: () => void; /** * The popover is anchored under this element and matches its width. Native * ignores it and presents an overlay instead, because a `Modal` resigns the * keyboard of whatever is behind it — an anchored dropdown over a focused * input is not something the platform can render. Web falls back to the same * overlay when no anchor is given. */ anchorRef?: RefObject; /** * Which edge of the anchor the panel lines up with. Anchored web only, like * {@link anchorRef} — `end` is what a menu hanging off a small trigger near * the right edge needs. */ align?: "end" | "start"; /** * `anchor` (the default) makes the panel as wide as the anchor, which is what * a field dropdown wants; `content` lets it size to its own content, for a * menu whose trigger is narrower than its items. Anchored web only. */ width?: "anchor" | "content"; /** * Which side of the anchor the panel opens on: `top` for a trigger pinned at * the bottom of the viewport (a sidebar's account row), where a panel below * would open off-screen. Anchored web only. */ side?: "bottom" | "top"; /** * Placement of the overlay presentation (native, and web without an anchor). * `top` keeps it pinned below the status bar so its first row stays put while * its content resizes; `center` (the default) suits content whose height does * not change while it is open. */ placement?: "center" | "top"; accent?: PortalAccentScopeProps["accent"]; "aria-label"?: string; children: ReactNode; } interface PopoverOverlayProps extends Pick< PopoverProps, "aria-label" | "children" | "onClose" > { placement: "center" | "top"; } // Split out of `Popover` so the insets are read *under* the Modal's own // SafeAreaProvider — see the comment on it below. function PopoverOverlay({ placement, "aria-label": ariaLabel, onClose, children, }: PopoverOverlayProps): ReactNode { const insets = useSafeAreaInsets(); return ( {/* Backdrop is an absolutely-filled sibling behind the content, so it catches outside presses without wrapping it — a `Pressable` around the content is `accessible` by default and would collapse the whole popover into one element for assistive tech (the listbox rows, the sheet's input). aria-hidden + focusable={false} keep this dismiss target out of the accessibility tree and the tab order. */} {children} ); } /** * Renders `children` above everything, outside the clipping of any * `overflow-hidden` ancestor (`surface` is one by design). Web portals into * `document.body` and anchors to `anchorRef`; native uses a transparent `Modal`. */ export function Popover({ open, onClose, placement = "center", accent, "aria-label": ariaLabel, children, }: PopoverProps): ReactNode { const reducedMotion = useReducedMotion(); return ( {/* A Modal is its own window: insets measured by an app-level provider describe the app's view, not this overlay — they are wrong whenever that provider is itself inset (the on-device Storybook renders the story below its own header, so it reports top: 0) and on Android, where the modal window has its own insets. A provider nested inside the Modal measures the overlay itself. It seeds from the parent insets, so there is no unmeasured first frame. */} {children} ); }