import { type ReactNode, useCallback } from 'react'; import { Pressable, ScrollView, useWindowDimensions, View } from 'react-native'; import { Easing } from 'react-native-reanimated'; import { CloseLine } from 'rn-motion-ui-icons/icons/close-line'; import { LeftLine } from 'rn-motion-ui-icons/icons/left-line'; 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 { CARD_RADIUS } from '../../../lib/radius'; 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 { OverlayShell } from '../Overlay/overlay-shell'; const BACK_BUTTON_HEADER_HEIGHT = 56; /** Narrow vs. wide layout cutoff — matches AdaptiveModal's default. */ const DEFAULT_WIDE_BREAKPOINT: BreakpointValue = 'sm'; type BuildBodyArgs = { mode: FullSheetMode; isSmallScreen: boolean; title: string | undefined; subtitle: string | undefined; showClose: boolean | undefined; dismissable: boolean; handleClose: () => void; customLayout: boolean; children: ReactNode; headerSlot: FullSheetProps['header']; compact: boolean; scrollable: boolean; px: string; pt: string; pb: string; closeIcon: ReactNode | undefined; backIcon: ReactNode | undefined; }; function buildBackButtonBody(args: BuildBodyArgs): ReactNode { const { isSmallScreen, title, dismissable, handleClose, children, backIcon } = args; const headerHeight = isSmallScreen && title ? BACK_BUTTON_HEADER_HEIGHT : 0; const backControl = backIcon ? ( {backIcon} ) : ( ); let backOverlay: ReactNode = null; if (isSmallScreen && title) backOverlay = ( {dismissable ? {backControl} : } {title} ); else if (dismissable) backOverlay = {backControl}; return ( <> {children} {backOverlay} ); } function resolveDefaultHeader(args: BuildBodyArgs): ReactNode { const { headerSlot, title, subtitle, showClose, compact, dismissable, handleClose, closeIcon } = args; if (headerSlot !== undefined) return typeof headerSlot === 'function' ? headerSlot({ close: handleClose }) : headerSlot; const hasHeader = Boolean(title || subtitle || showClose); if (!hasHeader) return null; return ( {title || subtitle ? ( {title ? ( {title} ) : null} {subtitle ? {subtitle} : null} ) : ( )} {showClose && dismissable ? (closeIcon ?? ( )) : null} ); } function buildDefaultBody(args: BuildBodyArgs): ReactNode { const { customLayout, children, scrollable, px, pt, pb } = args; if (customLayout) return children; const resolvedHeader = resolveDefaultHeader(args); return ( {resolvedHeader} {scrollable ? ( {children} ) : ( {children} )} ); } function buildBody(args: BuildBodyArgs): ReactNode { return args.mode === 'back-button' ? buildBackButtonBody(args) : buildDefaultBody(args); } export type FullSheetMode = 'default' | 'back-button'; export type FullSheetSlideFrom = 'bottom' | 'left' | 'right'; /** Context passed to the `header` render-prop. */ export type FullSheetHeaderCtx = { close: () => void }; export type FullSheetProps = { open?: boolean; /** * Fires when the sheet 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; /** Optional theme class for the entire sheet, including its safe-area padding. */ surfaceClassName?: string; /** Edge the full-screen surface slides from and returns to. Reduced motion fades instead. @default 'bottom' */ slideFrom?: FullSheetSlideFrom; title?: string; subtitle?: string; /** Show a close (X) button in the default-mode header. */ showClose?: boolean; /** Wrap content in a ScrollView (default true). */ scrollable?: boolean; /** Tighter horizontal/vertical padding (default false). */ compact?: boolean; /** Caller owns all layout — no header or content padding is applied. */ customLayout?: boolean; /** * Swap the sheet'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 sheet 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 of the sheet (0–3) — drives the background tint and the * `shadow-elevated-N` recipe (drop shadow + dark-mode rim). `0` is the flat * resting surface (no shadow or border). Defaults to `6`. */ elevation?: SurfaceElevation; /** * `'default'` — standard header with optional title and close button (default). * `'back-button'` — back arrow overlaid at top-left; caller owns content layout. */ mode?: FullSheetMode; /** When false, buttons are hidden and the sheet cannot be dismissed. */ dismissable?: boolean; /** Called after the close animation fully completes. */ onAfterClose?: () => void; /** * Replaces the built-in title/subtitle/X header. * Pass a ReactNode or a function `(ctx: FullSheetHeaderCtx) => ReactNode`. * When omitted, the existing title/subtitle/showClose props build the header. */ header?: ReactNode | ((ctx: FullSheetHeaderCtx) => ReactNode); /** Replace the entire close button. Default: a compact `IconButton` with a close glyph. */ closeIcon?: ReactNode; /** Replace the back-button chevron icon. Default: `LeftLine` in a compact `IconButton`. */ backIcon?: ReactNode; /** * Width at or above which the sheet uses its wide layout. A breakpoint name * from the shared scale or a raw pixel number. @default 'sm' (640) */ wideBreakpoint?: BreakpointValue; /** * Wrap content in device safe-area insets (status bar top, home indicator * bottom). Requires `react-native-safe-area-context` and `` * in the tree. Set to `false` to manage insets yourself. @default true */ safeArea?: boolean; /** * Fires after the sheet has fully presented (iOS `Modal.onShow`) — the moment * it is safe to request keyboard focus on content inside it. No-op on web. */ onShow?: () => void; testID?: string; }; export function FullSheet({ open, onOpenChange, onRequestClose, children, surfaceClassName, slideFrom = 'bottom', title, subtitle, showClose, scrollable = true, compact = false, customLayout = false, floating = false, glass = false, elevation = 3, mode = 'default', dismissable = true, onAfterClose, header: headerSlot, closeIcon, backIcon, wideBreakpoint = DEFAULT_WIDE_BREAKPOINT, safeArea = true, onShow, testID, }: FullSheetProps) { const isOpen = open ?? false; const { height, width } = useWindowDimensions(); const isSmallScreen = !isWidthAtLeast(width, wideBreakpoint); const reduced = useReducedMotion(); const insets = useSafeInsets(); const offscreen = reduced ? { opacity: 0, translateX: 0, translateY: 0, borderRadius: 0 } : { opacity: 1, translateX: slideFrom === 'bottom' ? 0 : width * (slideFrom === 'left' ? -1 : 1), translateY: slideFrom === 'bottom' ? height : 0, borderRadius: slideFrom === 'bottom' ? CARD_RADIUS : 0, }; const handleClose = useCallback(() => { onOpenChange?.(false); }, [onOpenChange]); const enterTransition = { type: 'timing' as const, duration: reduced ? 160 : 340, easing: reduced ? Easing.linear : Easing.out(Easing.cubic), }; const exitTransition = { type: 'timing' as const, duration: reduced ? 160 : 300, easing: reduced ? Easing.linear : Easing.in(Easing.cubic), }; const px = compact ? 'px-5' : 'px-6'; const pt = compact ? 'pt-6' : 'pt-8'; const pb = compact ? 'pb-5' : 'pb-6'; return ( {({ open: isAnimOpen, onExitComplete }) => { const body = buildBody({ mode, isSmallScreen, title, subtitle, showClose, dismissable, handleClose, customLayout, children, headerSlot, compact, scrollable, px, pt, pb, closeIcon, backIcon, }); return ( {isAnimOpen ? ( {body} ) : null} ); }} ); }