import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; import { type LayoutChangeEvent, Modal, Pressable, ScrollView, useWindowDimensions, View } from 'react-native'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { type BreakpointValue, isWidthAtLeast } from '../../../lib/breakpoints'; import { cn } from '../../../lib/cn'; import type { SurfaceElevation } from '../../../lib/elevated'; import { GLASS_SURFACE } from '../../../lib/glass'; 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 { BottomSheet } from '../BottomSheet/bottom-sheet'; import { OverlayOutlet } from '../Overlay/overlay-portal'; import { OverlayScrim } from '../Overlay/overlay-scrim'; import type { OverlayType } from '../Overlay/overlay-type'; /** Floating panel vs. bottom sheet cutoff. */ const DEFAULT_WIDE_BREAKPOINT: BreakpointValue = 'md'; const DEFAULT_WIDTH = 360; const VIEWPORT_PADDING = 8; const DEFAULT_MAX_HEIGHT = 520; const noop = () => undefined; type AnchorRect = { x: number; y: number; width: number; height: number }; type ComputePanelLayoutOptions = { anchor: AnchorRect | null; align: 'start' | 'end'; panelWidth: number; viewportWidth: number; viewportHeight: number; offset: number; contentHeight: number; }; type PanelLayout = { left: number; top: number; openAbove: boolean }; // Pure geometry for the floating panel: clamps it to the viewport and flips it // above the trigger when it wouldn't fit below. Pulled out of the component so // the math is testable and the render stays readable. function computePanelLayout(options: ComputePanelLayoutOptions): PanelLayout { const { anchor, align, panelWidth, viewportWidth, viewportHeight, offset, contentHeight } = options; const maxLeft = Math.max(VIEWPORT_PADDING, viewportWidth - panelWidth - VIEWPORT_PADDING); const spaceBelow = anchor ? viewportHeight - (anchor.y + anchor.height) - offset - VIEWPORT_PADDING : 0; const spaceAbove = anchor ? anchor.y - offset - VIEWPORT_PADDING : 0; const openAbove = Boolean(anchor) && contentHeight > spaceBelow && spaceAbove > spaceBelow; if (!anchor) return { left: 0, top: 0, openAbove: false }; const raw = align === 'end' ? anchor.x + anchor.width - panelWidth : anchor.x; const left = Math.min(Math.max(raw, VIEWPORT_PADDING), maxLeft); const top = openAbove ? Math.max(VIEWPORT_PADDING, anchor.y - offset - contentHeight) : anchor.y + anchor.height + offset; return { left, top, openAbove }; } export type TriggerRenderProps = { open: boolean; toggle: () => void }; export type ContentRenderProps = { close: () => void }; export type AdaptiveDropdownProps = { /** * The element that opens the dropdown. A plain node is wrapped in a Pressable * that toggles it. Pass a function to receive `{ open, toggle }` — use this * when the trigger is itself pressable (e.g. a `Button`): wire its `onPress` * to `toggle`, since the inner pressable claims the press and the wrapper's * own toggle never fires. */ trigger: ReactNode | ((props: TriggerRenderProps) => ReactNode); /** Content inside the floating panel / bottom sheet. */ children: ReactNode | ((props: ContentRenderProps) => ReactNode); /** Title shown in the panel header. */ title?: string; /** Trailing node in the panel header (e.g. an action button). */ headerSuffix?: ReactNode; /** Controls visibility from outside. Omit to let the component manage its own state. */ open?: boolean; onOpenChange?: (open: boolean) => void; triggerAccessibilityLabel?: string; /** Which edge of the trigger to align the panel to. @default 'start' */ align?: 'start' | 'end'; /** Panel width in pixels (wide screen only). @default 360 */ width?: number; /** Gap between trigger bottom and panel top. @default 8 */ offset?: number; /** Maximum panel height before content scrolls. @default 520 */ maxHeight?: number; /** When true, panel content is wrapped in a ScrollView. @default false */ scrollable?: boolean; /** * Class applied to the panel's content wrapper. The panel adds no inset of its * own, so this is where one goes: `"p-1"` for a menu whose rows should not run * to the edge, nothing at all for content that should. */ contentClassName?: string; /** Class applied to the trigger wrapper — use `"flex-1"` to stretch in a flex-row parent. */ triggerClassName?: string; /** When true, the bottom sheet on small screens stretches to full height. @default false */ fullSheet?: boolean; /** * Swap the panel's ladder shadow for the input field's large, diffuse halo * (`shadow-floating`). It replaces the `shadow-elevated-N` rung rather than * adding to it, so the panel keeps its `elevation` tint but trades the * layered drop for the halo. @default false */ floating?: boolean; /** Use the shared lightweight blur and glass rim on the panel. @default false */ glass?: boolean; /** Float level for the wide-screen panel — picks the `shadow-elevated-N` recipe (drop + dark rim). `0` is the flat resting surface (no shadow or border). @default 3 */ elevation?: SurfaceElevation; /** * Minimum window width for the floating-panel layout; below it the content * opens as a bottom sheet. A breakpoint name from the default scale or a raw * pixel number. @default 'md' */ wideBreakpoint?: BreakpointValue; /** * Open/close animation for the floating panel — the shared anchored-menu motion * every menu in this package uses. Override one field and the rest of the preset * stands. * * Wide screen only: below `wideBreakpoint` the content is a `BottomSheet`, which * slides from the edge on its own spring rather than scaling out of a corner. * Reduced motion overrides all of it — the panel cross-fades in place. * * @example * // Slower, and no slide * motion={{ enter: { damping: 30, stiffness: 180 }, offset: 0 }} */ motion?: MenuMotion; testID?: string; /** The scrim behind the panel: `"blur"`, `"opacity"`, or `"none"`. Defaults to `"none"` — a dropdown floats over the page untouched. */ overlay?: OverlayType; /** Overrides `overlay` on the small-screen bottom sheet. When omitted, the sheet uses `overlay`. */ smallScreenOverlay?: OverlayType; /** When false, pressing outside the panel will not close it. Defaults to true. */ closeOnOutsidePress?: boolean; /** * Fires after the dropdown has fully presented (iOS `Modal.onShow`) — the * moment it is safe to request keyboard focus on content inside it. Forwards to * the wide `Modal` or the narrow `BottomSheet`. No-op on web. */ onShow?: () => void; }; // biome-ignore lint/complexity/noExcessiveLinesPerFunction: the wide panel + narrow sheet share the same anchored-layout state — the line budget is the surface count, not branch depth export function AdaptiveDropdown({ trigger, children, title, headerSuffix, open: openProp, onOpenChange, triggerAccessibilityLabel, align = 'start', width = DEFAULT_WIDTH, offset = 8, maxHeight = DEFAULT_MAX_HEIGHT, scrollable = false, contentClassName, triggerClassName, fullSheet = false, floating = false, glass = false, elevation = 3, wideBreakpoint = DEFAULT_WIDE_BREAKPOINT, motion, testID, overlay = 'none', smallScreenOverlay, closeOnOutsidePress = true, onShow, }: AdaptiveDropdownProps) { const { width: vpWidth, height: vpHeight } = useWindowDimensions(); const isWideScreen = isWidthAtLeast(vpWidth, wideBreakpoint); const reduced = useReducedMotion(); const triggerRef = useRef(null); const [anchor, setAnchor] = useState(null); const [contentHeight, setContentHeight] = useState(0); const [internalOpen, setInternalOpen] = useState(false); const isControlled = openProp !== undefined; const open = isControlled ? openProp : internalOpen; const setOpen = useCallback( (next: boolean) => { if (!isControlled) setInternalOpen(next); onOpenChange?.(next); }, [isControlled, onOpenChange], ); const close = useCallback(() => setOpen(false), [setOpen]); // Floating panel mount state — immediately reset when leaving wide screen. const [panelMounted, setPanelMounted] = useState(false); // biome-ignore lint/plugin: responds to breakpoint flip — fires at the moment of change, not derivable from render state useEffect(() => { if (open && isWideScreen) setPanelMounted(true); else if (!isWideScreen) { setPanelMounted(false); setContentHeight(0); } }, [open, isWideScreen]); const handlePanelExitComplete = useCallback(() => { setPanelMounted(false); setContentHeight(0); }, []); const measure = useCallback(() => { triggerRef.current?.measureInWindow((x, y, w, h) => setAnchor({ x, y, width: w, height: h })); }, []); const toggle = useCallback(() => { if (!open) measure(); setOpen(!open); }, [open, measure, setOpen]); const handlePanelLayout = useCallback( (e: LayoutChangeEvent) => { const h = e.nativeEvent.layout.height; if (Math.abs(h - contentHeight) > 0.5) setContentHeight(h); }, [contentHeight], ); // ── Positioning ──────────────────────────────────────────────────────────── const panelWidth = width; const { left: panelLeft, top: panelTop, openAbove, } = computePanelLayout({ anchor, align, panelWidth, viewportWidth: vpWidth, viewportHeight: vpHeight, offset, contentHeight, }); // ── Content resolution ───────────────────────────────────────────────────── const isFunctionTrigger = typeof trigger === 'function'; const resolvedTrigger = isFunctionTrigger ? trigger({ open, toggle }) : trigger; const resolvedContent = typeof children === 'function' ? children({ close }) : children; const header = title || headerSuffix ? ( {title ? ( {title} ) : ( )} {headerSuffix} ) : null; const body = scrollable ? ( {resolvedContent} ) : ( {resolvedContent} ); // Shared with Popover and HoverMenu, so every panel this // package anchors to a trigger opens and closes the same way. const side = openAbove ? 'top' : 'bottom'; const panelMotion = resolveMenuMotion({ motion, reduce: reduced, side }); const transformOrigin = menuTransformOrigin({ align, side }); // The small-screen bottom sheet defaults to the same scrim as the wide panel; // `smallScreenOverlay` lets a consumer lighten (or drop) it on touch surfaces. const smallOverlay = smallScreenOverlay ?? overlay; return ( <> in it. accessibilityRole={isFunctionTrigger ? undefined : 'button'} aria-expanded={open} > {resolvedTrigger} {isWideScreen ? ( {open && isWideScreen ? ( {/* * Swallows presses so a tap on the panel's own padding doesn't * reach the dismiss Pressable filling the modal behind it. On * react-native-web that Pressable is a DOM node with a click * handler, and clicks bubble regardless of RN's responder * negotiation — so claiming the responder on the panel view is * not enough; a Pressable that handles the press is. */} {header} {body} ) : null} {/* Overlay outlet: above the panel, outside the scale animation. */} ) : ( {header} {body} )} ); }