import { forwardRef, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from 'react'; import { KeyboardAvoidingView, Platform, StyleSheet, Text, View, type TextProps, type ViewProps, } from 'react-native'; import { GestureDetector } from 'react-native-gesture-handler'; import Animated, { useSharedValue } from 'react-native-reanimated'; import { CheckCircle, Close as CloseIcon, EmergencyHome, Warning } from '@cdx-ui/icons'; import { DialogClose as DialogClosePrimitive, DialogContent as DialogContentPrimitive, DialogOverlay as DialogOverlayPrimitive, DialogPortal as DialogPortalPrimitive, DialogRoot as DialogRootPrimitive, DialogTrigger as DialogTriggerPrimitive, FullWindowOverlay, Slot, dataAttributes, mergeDataAttributes, useDialog, type IDialogCloseProps, type IDialogContentProps, type IDialogRootProps, type IDialogTriggerProps, } from '@cdx-ui/primitives'; import { cn, mergeRefs, ParentContext, useParentContext, useStyleContext } from '@cdx-ui/utils'; import { usePopupDialogContentAnimation, usePopupOverlayAnimation, usePopupRootAnimation, type AnimationRootDisableAll, } from '../../animation'; import { AnimationSettingsProvider, useAnimationSettings, } from '../../providers/animation-settings'; import { Icon } from '../Icon'; import { DialogAnimationProvider, useDialogAnimation } from './animation'; import { type DialogTitleVariantProps, type DialogVariantProps, dialogCloseIconVariants, dialogCloseVariants, dialogContentVariants, dialogDescriptionVariants, dialogFooterVariants, dialogHeaderVariants, dialogOverlayVariants, dialogPopupVariants, dialogPortalVariants, dialogTextVariants, dialogTitleIconVariants, dialogTitleVariants, } from './styles'; export { useDialog } from '@cdx-ui/primitives'; const IS_WEB = Platform.OS === 'web'; const WEB_TRANSITION_SAFETY_MS = 300; type DialogDataState = 'entering' | 'open' | 'closed'; // ============================================================================= // STYLE CONTEXT // ============================================================================= const SCOPE = 'DIALOG'; const useDialogStyleContext = () => useStyleContext(SCOPE) as DialogVariantProps; // ============================================================================= // ANIMATED PRIMITIVES // ============================================================================= const AnimatedOverlay = Animated.createAnimatedComponent(DialogOverlayPrimitive); // ============================================================================= // STYLED ROOT // ============================================================================= export interface DialogRootProps extends Omit, IDialogRootProps, DialogVariantProps { className?: string; animation?: AnimationRootDisableAll; } const DialogRoot = forwardRef( ({ size = 'md', fullBleed = false, animation, className, children, style, ...props }, ref) => { const parent = useParentContext(); const ctx = useMemo( () => ({ ...parent, [SCOPE]: { size, fullBleed } }), [parent, size, fullBleed], ); const { progress, isDragging, isGestureReleaseAnimationRunning, isAllAnimationsDisabled } = usePopupRootAnimation({ animation }); const animationContextValue = useMemo( () => ({ progress, isDragging, isGestureReleaseAnimationRunning }), [progress, isDragging, isGestureReleaseAnimationRunning], ); const animationSettingsContextValue = useMemo( () => ({ isAllAnimationsDisabled }), [isAllAnimationsDisabled], ); return ( {children} ); }, ); DialogRoot.displayName = 'Dialog'; // ============================================================================= // STYLED TRIGGER // ============================================================================= export interface DialogTriggerProps extends IDialogTriggerProps { className?: string; children?: ReactNode; } const DialogTrigger = forwardRef( ({ className, children, style, ...props }, ref) => ( {children} ), ); DialogTrigger.displayName = 'Dialog.Trigger'; // ============================================================================= // STYLED POPUP (portal + overlay + content composition) // ============================================================================= export interface DialogPopupProps extends Omit, Pick { className?: string; children?: ReactNode; disableFullWindowOverlay?: boolean; isSwipeable?: boolean; } const DialogPopup = forwardRef( ( { size: sizeProp, className, children, style, forceMount = false, avoidKeyboard = true, keyboardVerticalOffset = 0, disableFullWindowOverlay = false, isSwipeable = true, ...props }, ref, ) => { const { size: contextSize } = useDialogStyleContext(); const size = sizeProp ?? contextSize; const { open, onOpenChange } = useDialog(); const parentContext = useParentContext(); const animationContext = useDialogAnimation(); const parentAnimationSettings = useAnimationSettings(); const fallbackProgress = useSharedValue(0); const fallbackIsDragging = useSharedValue(false); const fallbackIsGestureReleaseAnimationRunning = useSharedValue(false); const progress = animationContext?.progress ?? fallbackProgress; const isDragging = animationContext?.isDragging ?? fallbackIsDragging; const isGestureReleaseAnimationRunning = animationContext?.isGestureReleaseAnimationRunning ?? fallbackIsGestureReleaseAnimationRunning; const { rContainerStyle, entering: overlayEntering, exiting: overlayExiting, } = usePopupOverlayAnimation({ progress, isDragging, isGestureReleaseAnimationRunning, }); const { contentY, contentHeight, panGesture, rDragContainerStyle, entering: contentEntering, exiting: contentExiting, } = usePopupDialogContentAnimation({ isOpen: open, progress, isDragging, isGestureReleaseAnimationRunning, onOpenChange, isSwipeable, }); const dragContainerRef = useRef(null); useLayoutEffect(() => { if (!open || IS_WEB) return; dragContainerRef.current?.measure((_x, _y, _width, height, _pageX, pageY) => { contentY.set(pageY); contentHeight.set(height); }); }, [open, contentY, contentHeight]); // ---- Web enter/exit lifecycle ---- // Mirrors the Toast pattern: mount → 'entering' → (rAF) → 'open' → (close) // → 'closed' → (transitionend) → unmount. // // Critical: `isExiting` is derived synchronously (via ref tracking the previous // `open` value) so the component stays mounted in the SAME render that sees // `open` go false — if we used useEffect the component would return null before // the effect fires. // // The transitionend listener targets the popup element (via popupRef) instead of // the portal wrapper. CSS exit transitions live on the overlay and popup children, // not the wrapper — listening on the wrapper would unmount on the first bubbled // event, potentially cutting the other child's animation short. const wrapperRef = useRef(null); const popupRef = useRef(null); const prevOpenRef = useRef(open); const [isExiting, setIsExiting] = useState(false); const [webState, setWebState] = useState(open ? 'entering' : 'closed'); // Detect open → false transition synchronously during render if (IS_WEB && prevOpenRef.current && !open && !isExiting) { setIsExiting(true); setWebState('closed'); } // Detect closed → open transition synchronously during render if (IS_WEB && !prevOpenRef.current && open) { setIsExiting(false); setWebState('entering'); } prevOpenRef.current = open; // Entrance: commit 'entering' on first paint, then transition to 'open' useEffect(() => { if (!IS_WEB || !open) return; const raf = requestAnimationFrame(() => { setWebState('open'); }); return () => cancelAnimationFrame(raf); }, [open]); // Exit: wait for CSS transition to finish, then unmount. // Listens on the popup element directly — its exit transitions (opacity + // transform) are at least as long as the overlay's, so when the popup // finishes both children are done animating. useEffect(() => { if (!IS_WEB || !isExiting) return; const node = popupRef.current as unknown as HTMLElement | null; let done = false; const finish = () => { if (done) return; done = true; setIsExiting(false); }; const safetyTimer = setTimeout(finish, WEB_TRANSITION_SAFETY_MS); if (!node || typeof node.addEventListener !== 'function') { return () => { clearTimeout(safetyTimer); }; } const handler = (event: Event) => { if (event.target !== node) return; const propertyName = (event as unknown as { propertyName?: string }).propertyName ?? ''; if (propertyName === 'opacity' || propertyName === 'transform') { finish(); } }; node.addEventListener('transitionend', handler); return () => { clearTimeout(safetyTimer); node.removeEventListener('transitionend', handler); }; }, [isExiting]); const dataState: DialogDataState = webState; const shouldMount = IS_WEB ? open || isExiting : open; if (!forceMount && !shouldMount) { return null; } const portalClassName = cn(dialogPortalVariants()); const overlayClassName = cn(dialogOverlayVariants()); const popupClassName = cn(dialogPopupVariants({ size }), className); const keyboardBehavior = avoidKeyboard ? Platform.select<'padding' | 'height'>({ ios: 'padding', default: 'height' }) : undefined; return ( {/* Overlay */} {/* Content with gesture + animation */} {children} ); }, ); DialogPopup.displayName = 'Dialog.Popup'; // ============================================================================= // STYLED HEADER (styled-only sub-slot) // ============================================================================= export interface DialogHeaderProps extends ViewProps { asChild?: boolean; className?: string; children?: ReactNode; } const DialogHeader = forwardRef( ({ asChild, className, children, style, ...props }, ref) => { const computedClassName = cn(dialogHeaderVariants(), className); const Comp = asChild ? Slot : View; return ( {children} ); }, ); DialogHeader.displayName = 'Dialog.Header'; // ============================================================================= // STYLED TITLE (styled-only sub-slot with severity variants) // ============================================================================= export type DialogTitleSeverity = 'neutral' | 'danger' | 'warning' | 'success'; /** @deprecated Use `severity` prop instead of `appearance`. */ export type DialogTitleAppearance = 'default' | 'danger' | 'warning' | 'success'; let hasWarnedDialogAppearanceProp = false; let hasWarnedDialogDefaultSeverity = false; const SEVERITY_ICON_MAP = { danger: EmergencyHome, warning: Warning, success: CheckCircle, } as const; export interface DialogTitleProps extends TextProps, Omit { asChild?: boolean; /** * Severity level that controls the status icon and color treatment. * @default 'neutral' */ severity?: | DialogTitleSeverity /** @deprecated Use `'neutral'` instead. */ | 'default'; /** * @deprecated Use `severity` prop instead. */ appearance?: DialogTitleAppearance; className?: string; children?: ReactNode; } const DialogTitle = forwardRef( ({ asChild, severity: severityProp, appearance, className, children, style, ...props }, ref) => { useEffect(() => { if (__DEV__ && appearance !== undefined && !hasWarnedDialogAppearanceProp) { hasWarnedDialogAppearanceProp = true; console.warn( '[Dialog.Title] The `appearance` prop is deprecated and will be removed in a future major release. Use `severity` instead.', ); } }, [appearance]); const rawSeverity = severityProp ?? appearance ?? 'neutral'; useEffect(() => { if (__DEV__ && rawSeverity === 'default' && !hasWarnedDialogDefaultSeverity) { hasWarnedDialogDefaultSeverity = true; console.warn( '[Dialog.Title] The `default` severity value is deprecated and will be removed in a future major release. Use `neutral` instead.', ); } }, [rawSeverity]); const severity: DialogTitleSeverity = rawSeverity === 'default' ? 'neutral' : rawSeverity; const computedClassName = cn(dialogTitleVariants({ severity }), className); const IconComponent = severity && severity in SEVERITY_ICON_MAP ? SEVERITY_ICON_MAP[severity as keyof typeof SEVERITY_ICON_MAP] : null; const Comp = asChild ? Slot : Text; const titleElement = ( {children} ); if (IconComponent) { return ( {titleElement} ); } return titleElement; }, ); DialogTitle.displayName = 'Dialog.Title'; // ============================================================================= // STYLED DESCRIPTION (styled-only sub-slot — reads dialog context for nativeID) // ============================================================================= export interface DialogDescriptionProps extends TextProps { asChild?: boolean; className?: string; children?: ReactNode; } const DialogDescription = forwardRef( ({ asChild, className, children, style, ...props }, ref) => { const { nativeID } = useDialog(); const computedClassName = cn(dialogDescriptionVariants(), className); const Comp = asChild ? Slot : Text; return ( {children} ); }, ); DialogDescription.displayName = 'Dialog.Description'; // ============================================================================= // STYLED CONTENT (styled-only sub-slot — reads variant context for fullBleed) // ============================================================================= export interface DialogContentProps extends ViewProps { asChild?: boolean; className?: string; children?: ReactNode; } const DialogContent = forwardRef( ({ asChild, className, children, style, ...props }, ref) => { const { fullBleed } = useDialogStyleContext(); const computedClassName = cn( dialogContentVariants({ fullBleed: fullBleed ?? false }), className, ); const Comp = asChild ? Slot : View; return ( {children} ); }, ); DialogContent.displayName = 'Dialog.Content'; // ============================================================================= // STYLED TEXT (convenience text styling, styled-only) // ============================================================================= export interface DialogTextProps extends TextProps { asChild?: boolean; className?: string; children?: ReactNode; } const DialogText = forwardRef( ({ asChild, className, children, style, ...props }, ref) => { const computedClassName = cn(dialogTextVariants(), className); const Comp = asChild ? Slot : Text; return ( {children} ); }, ); DialogText.displayName = 'Dialog.Text'; // ============================================================================= // STYLED FOOTER (styled-only sub-slot) // ============================================================================= export interface DialogFooterProps extends ViewProps { asChild?: boolean; className?: string; children?: ReactNode; } const DialogFooter = forwardRef( ({ asChild, className, children, style, ...props }, ref) => { const computedClassName = cn(dialogFooterVariants(), className); const Comp = asChild ? Slot : View; return ( {children} ); }, ); DialogFooter.displayName = 'Dialog.Footer'; // ============================================================================= // STYLED CLOSE // ============================================================================= export interface DialogCloseProps extends IDialogCloseProps { className?: string; children?: ReactNode; } const DialogClose = forwardRef( ({ className, accessibilityLabel = 'Close', children, style, ...props }, ref) => { const computedClassName = cn(dialogCloseVariants(), className); return ( {children ?? } ); }, ); DialogClose.displayName = 'Dialog.Close'; // ============================================================================= // COMPOUND COMPONENT // ============================================================================= type DialogCompoundComponent = typeof DialogRoot & { Trigger: typeof DialogTrigger; Popup: typeof DialogPopup; Header: typeof DialogHeader; Title: typeof DialogTitle; Description: typeof DialogDescription; Content: typeof DialogContent; Text: typeof DialogText; Footer: typeof DialogFooter; Close: typeof DialogClose; }; export const Dialog = Object.assign(DialogRoot, { Trigger: DialogTrigger, Popup: DialogPopup, Header: DialogHeader, Title: DialogTitle, Description: DialogDescription, Content: DialogContent, Text: DialogText, Footer: DialogFooter, Close: DialogClose, }) as DialogCompoundComponent; // ============================================================================= // INTERNAL STYLES // ============================================================================= const internalStyles = StyleSheet.create({ fill: { flex: 1, }, center: { flex: 1, alignItems: 'center', justifyContent: 'center', }, });