// biome-ignore-all lint/style/noExcessiveLinesPerFile: three surfaces (bottomSheet / fullSheet / wide modal+drawer) share one render path — splitting scatters tightly-coupled layout state import { type ReactNode, useCallback, useEffect, useState } from 'react'; import { Modal, ScrollView, TouchableOpacity, useWindowDimensions, View } from 'react-native'; import { Easing } from 'react-native-reanimated'; import { CloseLine } from 'rn-motion-ui-icons/icons/close-line'; import { useModalRender } from '../../../hooks/use-modal-render'; import { useReducedMotion } from '../../../hooks/use-reduced-motion'; import { useSafeInsets } from '../../../hooks/use-safe-insets'; 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 { IconButton } from '../../buttons/IconButton/icon-button'; import { Surface } from '../../display/Surface/surface'; import { Text } from '../../typography/Text/text'; import { BottomSheet } from '../BottomSheet/bottom-sheet'; import { FullSheet, type FullSheetSlideFrom } from '../FullSheet/full-sheet'; import { OverlayOutlet } from '../Overlay/overlay-portal'; import { OverlayScrim } from '../Overlay/overlay-scrim'; import type { OverlayType } from '../Overlay/overlay-type'; /** Narrow vs. wide layout cutoff — matches FullSheet's default. */ const DEFAULT_WIDE_BREAKPOINT: BreakpointValue = 'sm'; /** * Freezes `value` while `active` is false, so exit animations keep showing the * last children/title/subtitle instead of collapsing mid-exit. */ function useLatchedValue(value: T, active: boolean): T { const [latched, setLatched] = useState(value); // biome-ignore lint/plugin: latched value must trail `value` only while active — syncing state to a prop is intentional, not derivable useEffect(() => { if (active) setLatched(value); }, [value, active]); return active ? value : latched; } type AdaptiveModalProps = { open?: boolean; /** * Fires when the modal asks to be dismissed — always with `false`. `open` is * the consumer's own state, so there is no open direction to report. */ onOpenChange?: (open: boolean) => void; /** Handle system back/Escape separately from explicit dismissal. */ onRequestClose?: () => void; children: ReactNode; /** Theme class for the narrow full-sheet surface; wide panels keep their own surface. */ smallScreenSurfaceClassName?: string; /** Entry/exit edge for the small-screen full sheet. @default 'bottom' */ smallScreenSlideFrom?: FullSheetSlideFrom; /** Theme class for the wide modal or drawer surface. */ largeScreenSurfaceClassName?: string; title?: string; subtitle?: string; showClose?: boolean; smallScreenMode?: SmallScreenMode; largeScreenMode?: LargeScreenMode; scrollable?: boolean; /** * Static size for the centered desktop panel (largeScreenMode="modal"). * Each dimension accepts a pixel number or a percentage string (e.g. `'80%'`), resolved against the window size. * When omitted, the panel sizes to content with `min-w-xl max-w-xl` and a max-height cap. */ widePanelSize?: WidePanelSize; /** Reduces the padding around the header and content. */ compact?: boolean; /** Overrides the breakpoint-derived wide/narrow layout decision. */ isWideScreen?: boolean; /** * Width at which the wide (modal / drawer) layout takes over — a breakpoint * name or a raw pixel number. @default 'sm' (640) */ wideBreakpoint?: BreakpointValue; /** When true, the caller fully owns the content layout and the modal applies no container padding. */ customLayout?: boolean; /** Called after the close animation has fully completed and the modal is unmounted. */ onAfterClose?: () => void; /** * Fires after the modal 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` / `FullSheet`. No-op on web. */ onShow?: () => void; /** The scrim behind the panel: `"blur"`, `"opacity"`, or `"none"`. Defaults to `"blur"`. */ 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 the modal. Defaults to true. */ closeOnOutsidePress?: boolean; /** * Swap the wide (desktop) 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; /** Surface elevation (0–3) for the wide (desktop) panel — drives the drop shadow + dark-mode rim. `0` is the flat resting surface (no shadow or border). Defaults to 3. */ elevation?: SurfaceElevation; /** * Wrap content in device safe-area insets. Passed through to BottomSheet / * FullSheet on narrow screens; applied directly to the right-drawer panel on * wide screens. Requires `react-native-safe-area-context` and * `` in the tree. Set to `false` to manage insets yourself. * @default true */ safeArea?: boolean; testID?: string; }; function resolvePanelDimension(value: Dimension | undefined, viewportSize: number): number | undefined { if (value === undefined) return; return typeof value === 'number' ? value : Math.round((Number.parseFloat(value) / 100) * viewportSize); } type LayoutPaddingContext = { customLayout: boolean; compact: boolean; isWideScreen: boolean; isBottomSheet: boolean }; function resolveLayoutPaddingClass( context: LayoutPaddingContext, compactClasses: readonly [string, string, string], defaultClasses: readonly [string, string, string], ): string { if (context.customLayout) return ''; const classes = context.compact ? compactClasses : defaultClasses; if (context.isWideScreen) return classes[0]; return context.isBottomSheet ? classes[1] : classes[2]; } type CenteredPanelGeometry = { wideWidth: number | undefined; wideHeight: number | undefined; wideMaxWidth: number | undefined; wideMaxHeight: number | undefined; maxModalHeight: number; }; export type SmallScreenMode = 'bottomSheet' | 'fullSheet'; export type LargeScreenMode = 'modal' | 'rightDrawer'; export type Dimension = number | `${number}%`; export type WidePanelSize = { width?: Dimension; height?: Dimension; maxWidth?: Dimension; maxHeight?: Dimension }; // biome-ignore lint/complexity/noExcessiveLinesPerFunction: the three branches share header/content/padding helpers — the line budget is the surface count, not branch depth export function AdaptiveModal({ open: openProp, onOpenChange, onRequestClose, children, smallScreenSurfaceClassName, smallScreenSlideFrom, largeScreenSurfaceClassName, title, subtitle, showClose, smallScreenMode = 'fullSheet', largeScreenMode = 'modal', scrollable = true, widePanelSize, compact = false, isWideScreen: isWideScreenOverride, wideBreakpoint = DEFAULT_WIDE_BREAKPOINT, customLayout = false, onAfterClose, onShow, overlay = 'blur', smallScreenOverlay, closeOnOutsidePress = true, floating = false, glass = false, elevation = 3, safeArea = true, testID, }: AdaptiveModalProps) { const reduce = useReducedMotion(); const { height, width } = useWindowDimensions(); const isWideScreen = isWideScreenOverride ?? isWidthAtLeast(width, wideBreakpoint); const insets = useSafeInsets(); const open = openProp ?? false; const handleClose = useCallback(() => { onOpenChange?.(false); }, [onOpenChange]); const isBottomSheet = smallScreenMode === 'bottomSheet'; const isRightDrawer = largeScreenMode === 'rightDrawer'; // 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; const desktopEnterOffset = 18; const desktopExitOffset = 10; const drawerEnterOffset = 52; const drawerExitOffset = 26; const overlayTransition = reduce ? { type: 'timing' as const, duration: 120, easing: Easing.linear } : { type: 'timing' as const, duration: 220, easing: Easing.out(Easing.cubic) }; const desktopPanelTransition = reduce ? { type: 'timing' as const, duration: 140, easing: Easing.linear } : { type: 'spring' as const, damping: 24, stiffness: 260, mass: 0.95 }; const drawerPanelTransition = reduce ? { type: 'timing' as const, duration: 140, easing: Easing.linear } : { type: 'spring' as const, damping: 27, stiffness: 280, mass: 0.94 }; const panelExitTransition = reduce ? { type: 'timing' as const, duration: 90, easing: Easing.linear } : { type: 'timing' as const, duration: 180, easing: Easing.in(Easing.cubic) }; const renderedChildren = useLatchedValue(children, open); const renderedTitle = useLatchedValue(title, open); const renderedSubtitle = useLatchedValue(subtitle, open); // The wide surface owns its own Modal + AnimatePresence. useModalRender keeps // it mounted until the exit animation finishes (mirrors FullSheet/MorphingModal). // When the screen narrows mid-open, `isWideOpen` flips false and the wide branch // stops rendering entirely (React unmounts the Modal), so no exit anim plays — // the narrow surface takes over immediately. const isWideOpen = open && isWideScreen; const { rendered: isWideMounted, onExitComplete } = useModalRender(isWideOpen); const closeButton = showClose ? ( ) : null; const handleExitComplete = useCallback(() => { onExitComplete(); onAfterClose?.(); }, [onExitComplete, onAfterClose]); const hasHeader = Boolean(renderedTitle || renderedSubtitle || showClose); const paddingContext: LayoutPaddingContext = { customLayout, compact, isWideScreen, isBottomSheet }; const containerPaddingClass = resolveLayoutPaddingClass( paddingContext, ['px-6 pt-6', 'px-6 pt-3', 'px-5 pt-6'], ['px-10 pt-8', 'px-8 pt-4', 'px-6 pt-8'], ); const contentBottomPaddingClass = resolveLayoutPaddingClass(paddingContext, ['pb-6', 'pb-3', 'pb-5'], ['pb-8', 'pb-5', 'pb-6']); const renderHeader = () => hasHeader ? ( {renderedTitle || renderedSubtitle ? ( {renderedTitle ? ( {renderedTitle} ) : null} {renderedSubtitle ? ( {renderedSubtitle} ) : null} ) : ( )} {closeButton} ) : null; const renderContent = useCallback( (contentClassName?: string) => { if (scrollable) return ( {renderedChildren} ); return {renderedChildren}; }, [renderedChildren, contentBottomPaddingClass, scrollable], ); const renderBottomSheetContent = useCallback(() => { if (scrollable) return ( {renderedChildren} ); return {renderedChildren}; }, [renderedChildren, contentBottomPaddingClass, height, scrollable]); const renderDrawerPanel = (drawerWidth: number) => ( {renderHeader()} {renderContent()} ); const renderCenteredPanel = (geometry: CenteredPanelGeometry) => { const { wideWidth, wideHeight, wideMaxWidth, wideMaxHeight, maxModalHeight } = geometry; return ( {renderHeader()} {renderContent()} ); }; const renderWideSurface = () => { const drawerWidth = Math.min(760, Math.max(460, Math.round(width * 0.44))); const maxModalHeight = Math.min(height - 80, 976); const wideWidth = resolvePanelDimension(widePanelSize?.width, width); const wideHeight = resolvePanelDimension(widePanelSize?.height, height); const wideMaxWidth = resolvePanelDimension(widePanelSize?.maxWidth, width); const wideMaxHeight = resolvePanelDimension(widePanelSize?.maxHeight, height); return ( {isWideOpen ? ( {isRightDrawer ? renderDrawerPanel(drawerWidth) : renderCenteredPanel({ wideWidth, wideHeight, wideMaxWidth, wideMaxHeight, maxModalHeight })} ) : null} {/* Overlay outlet: above the panel, outside the scrim and panel animations. */} ); }; if (isWideScreen) return renderWideSurface(); return isBottomSheet ? ( {renderHeader()} {renderBottomSheetContent()} ) : ( {renderHeader()} {renderContent()} ); }