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}
);
}}
);
}