import { cloneElement, createContext, forwardRef, isValidElement, useCallback, useContext, useMemo, useState, type ReactElement, type ReactNode, } from 'react'; import { Pressable, View, type ViewProps } from 'react-native'; import Animated, { FadeIn, FadeOut, ZoomIn } from 'react-native-reanimated'; import { tv, type VariantProps } from 'tailwind-variants'; import { ModalPortal } from '../../primitives/portal'; import { Scrim } from '../../primitives/scrim'; import { Text, type TextProps, textChildren } from '../../primitives/text'; import { useBackHandler } from '../../hooks/use-back-handler'; import { cn } from '../../utils/cn'; interface DialogContextValue { open: boolean; setOpen: (open: boolean) => void; } const DialogContext = createContext(null); function useDialog(component: string): DialogContextValue { const context = useContext(DialogContext); if (!context) { throw new Error(`${component} must be used within a `); } return context; } export interface DialogProps { children: ReactNode; /** Controlled open state. */ open?: boolean; onOpenChange?: (open: boolean) => void; /** Initial state when uncontrolled. */ defaultOpen?: boolean; } function DialogRoot({ children, open, onOpenChange, defaultOpen = false }: DialogProps) { const [internalOpen, setInternalOpen] = useState(defaultOpen); const isControlled = open !== undefined; const resolvedOpen = isControlled ? open : internalOpen; const setOpen = useCallback( (next: boolean) => { if (!isControlled) setInternalOpen(next); onOpenChange?.(next); }, [isControlled, onOpenChange] ); const context = useMemo( () => ({ open: resolvedOpen, setOpen }), [resolvedOpen, setOpen] ); return {children}; } interface DialogTriggerProps { children: ReactElement<{ onPress?: (...args: unknown[]) => void }>; } /** Wraps its child and opens the dialog on press. */ function DialogTrigger({ children }: DialogTriggerProps) { const { setOpen } = useDialog('Dialog.Trigger'); if (!isValidElement(children)) return children; return cloneElement(children, { onPress: (...args: unknown[]) => { children.props.onPress?.(...args); setOpen(true); }, }); } export interface DialogContentProps extends ViewProps { className?: string; /** Tap on the backdrop closes the dialog. Default true. */ dismissible?: boolean; /** * Frost the screen behind the dialog instead of dimming it. Uses `expo-blur` * when installed and falls back to the dim when it is not, so it is safe to * pass either way. * * Someone who has Reduce Transparency switched on gets an opaque * backdrop instead, which is the whole point of the setting. */ blur?: boolean; children?: ReactNode; } function DialogContent({ className, dismissible = true, blur = false, children, ...props }: DialogContentProps) { const context = useDialog('Dialog.Content'); const { open, setOpen } = context; // While the dialog is up, the Android back button closes it rather than // popping the screen behind — but only when tapping outside would too. useBackHandler(open && dismissible, () => setOpen(false)); if (!open) return null; return ( {/* Portal content mounts under PortalHost, outside this provider's subtree — re-provide the context so Dialog.Close etc. keep working. */} setOpen(false) : undefined} /> {textChildren(children)} ); } const DialogTitle = forwardRef, TextProps>( ({ className, ...props }, ref) => ( ) ); DialogTitle.displayName = 'Dialog.Title'; const DialogDescription = forwardRef, TextProps>( ({ className, ...props }, ref) => ( ) ); DialogDescription.displayName = 'Dialog.Description'; /** * The dialog's padding, in points. The panel footer bleeds back out through it, * so the two have to agree — a mismatch is a band inset from one edge. */ const DIALOG_PADDING = 20; const dialogFooterVariants = tv({ base: 'flex-row items-center justify-end gap-2', variants: { /** * Whether the footer draws a surface of its own. * * `plain` is part of the dialog: same background, sitting under the content * with a gap above it. `panel` is a band set into it — a rule across the * top, a step darker, and the dialog's own bottom corners — which separates * what the dialog says from what you can do about it. Worth it on a dialog * with a form in it, where the buttons are otherwise one more row of it. */ variant: { plain: 'mt-4', panel: 'mt-5 rounded-b-2xl border-t border-border bg-inset', }, }, defaultVariants: { variant: 'plain', }, }); export interface DialogFooterProps extends ViewProps, VariantProps { className?: string; } function DialogFooter({ className, variant, style, ...props }: DialogFooterProps) { return ( ); } interface DialogCloseProps { children: ReactElement<{ onPress?: (...args: unknown[]) => void }>; } /** Wraps its child and closes the dialog on press. */ function DialogClose({ children }: DialogCloseProps) { const { setOpen } = useDialog('Dialog.Close'); if (!isValidElement(children)) return children; return cloneElement(children, { onPress: (...args: unknown[]) => { children.props.onPress?.(...args); setOpen(false); }, }); } export const Dialog = Object.assign(DialogRoot, { Trigger: DialogTrigger, Content: DialogContent, Title: DialogTitle, Description: DialogDescription, Footer: DialogFooter, Close: DialogClose, });