import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'; import { Platform, View } from 'react-native'; import { GestureDetector } from 'react-native-gesture-handler'; import Animated from 'react-native-reanimated'; import { cn, ParentContext, useParentContext, useStyleContext } from '@cdx-ui/utils'; import { CloseIcon } from '@cdx-ui/icons'; import { ToastRoot as ToastRootPrimitive, useToastContext, Slot, dataAttributes, mergeDataAttributes, } from '@cdx-ui/primitives'; import { AnimationSettingsProvider } from '../../providers/animation-settings'; import type { ToastComponentProps } from '../../providers/toast'; import { useToastLifecycle } from '../../providers/toast/lifecycle.context'; import { useToastConfig } from '../../providers/toast/toast-config.context'; import { Button } from '../Button'; import { Text } from '../Text'; import { Icon } from '../Icon'; import { IconButton } from '../IconButton'; import { useToastRootAnimation } from './animation'; import { useButtonColor, useVerticalPlaceholderStyles } from './hooks'; import { toastRootVariants, toastLabelVariants, toastDescriptionVariants, toastIconVariants, toastStyleSheet, toastWrapperVariants, } from './styles'; import type { DefaultToastProps, ToastActionProps, ToastCloseProps, ToastColorInternal, ToastContextValue, ToastDescriptionProps, ToastIconProps, ToastProps, ToastTitleProps, } from './types'; const SCOPE = 'TOAST'; const useToastStyleContext = () => useStyleContext(SCOPE) as ToastContextValue; const AnimatedToastRoot = Animated.createAnimatedComponent(ToastRootPrimitive); const IS_WEB = Platform.OS === 'web'; let hasWarnedToastDefaultColor = false; /** * Fallback timeout for web exit transitions. Covers cases where * `transitionend` never fires — `prefers-reduced-motion: reduce` * collapsing the transition, a backgrounded tab, or the element being * hidden before it triggers. Longer than the longest CSS transition * (200ms enter / 150ms exit) plus a ~100ms buffer. */ const WEB_TRANSITION_SAFETY_MS = 300; type ToastDataState = 'entering' | 'open' | 'closed'; // -------------------------------------------------- const ToastRoot = forwardRef((props, ref) => { const globalConfig = useToastConfig(); const { children, color: localColor, placement: localPlacement, index, total, heights, maxVisibleToasts, className, style, animation: localAnimation, isSwipeable: localIsSwipeable, isAnimatedStyleActive = true, hide, ...restProps } = props; /** * Merge global config with local props, ensuring local props take precedence */ const resolvedColor = localColor ?? globalConfig?.color ?? 'neutral'; const placement = localPlacement ?? globalConfig?.placement ?? 'bottom'; const animation = localAnimation ?? globalConfig?.animation; const isSwipeable = localIsSwipeable ?? globalConfig?.isSwipeable; useEffect(() => { if (__DEV__ && resolvedColor === ('default' as string) && !hasWarnedToastDefaultColor) { hasWarnedToastDefaultColor = true; console.warn( '[Toast] The `default` color is deprecated and will be removed in a future major release. Use `neutral` instead.', ); } }, [resolvedColor]); const color: NonNullable = (resolvedColor as string) === 'default' ? 'neutral' : (resolvedColor as NonNullable); // Access id from props (id is omitted from ToastProps type but available at runtime) const toastProps = props as ToastProps & Pick; const { id } = toastProps; const rootClassName = cn(toastRootVariants({ color }), className); // Extract padding and backgroundColor for placeholder Views const { topStyle, bottomStyle } = useVerticalPlaceholderStyles({ rootClassName, style, }); const { rContainerStyle, entering, exiting, panGesture, isAllAnimationsDisabled } = useToastRootAnimation({ animation, index, total, heights, placement, hide, id, isSwipeable, maxVisibleToasts, }); const rootStyle = isAnimatedStyleActive ? [toastStyleSheet.root, rContainerStyle, style] : [toastStyleSheet.root, style]; const animationSettingsContextValue = useMemo( () => ({ isAllAnimationsDisabled, }), [isAllAnimationsDisabled], ); const parent = useParentContext(); const ctx = useMemo( () => ({ ...parent, [SCOPE]: { color, hide, id } }), [parent, color, hide, id], ); // ---- Web enter/exit lifecycle ----------------------------------------- // On web the CSS engine drives enter/exit via `data-state` transitions // (see `toastWrapperVariants`). The state machine is: // mount → `entering` → (rAF) → `open` → (hide) → `closed` → (transitionend) → unmount // Native ignores `dataState`; Reanimated `entering` / `exiting` still handle the visuals. const lifecycle = useToastLifecycle(); const isPendingRemoval = lifecycle?.pendingRemovalIds.has(id) ?? false; const isFrontmost = lifecycle?.frontmostId === id; const [localState, setLocalState] = useState>('entering'); useEffect(() => { if (!IS_WEB) return; // rAF ensures the first paint commits with `data-state="entering"` // (opacity 0 + slight translate), then the next frame transitions // to `data-state="open"` with the CSS engine driving the animation. const raf = requestAnimationFrame(() => { setLocalState('open'); }); return () => cancelAnimationFrame(raf); }, []); const dataState: ToastDataState = isPendingRemoval ? 'closed' : localState; const wrapperRef = useRef(null); // Keep the latest `remove` reachable from a ref so the pending-removal effect // below doesn't list `lifecycle` in its dep array. The context value changes // whenever any toast in the stack updates, which would otherwise reset the // safety timeout on every unrelated re-render. const removeRef = useRef<((id: string) => void) | undefined>(lifecycle?.remove); removeRef.current = lifecycle?.remove; useEffect(() => { if (!IS_WEB) return; if (!isPendingRemoval) return; const node = wrapperRef.current as unknown as | (HTMLElement & { addEventListener: HTMLElement['addEventListener']; removeEventListener: HTMLElement['removeEventListener']; }) | null; let removed = false; const doRemove = () => { if (removed) return; removed = true; removeRef.current?.(id); }; const safetyTimer = setTimeout(doRemove, WEB_TRANSITION_SAFETY_MS); if (!node || typeof node.addEventListener !== 'function') { return () => { clearTimeout(safetyTimer); }; } const handler = (event: Event) => { const propertyName = (event as unknown as { propertyName?: string }).propertyName ?? ''; // Ignore transitions on unrelated properties (e.g. shadow tokens) so // we don't unmount before the exit animation actually completes. if (propertyName === 'opacity' || propertyName === 'transform') { doRemove(); } }; node.addEventListener('transitionend', handler); return () => { clearTimeout(safetyTimer); node.removeEventListener('transitionend', handler); }; }, [isPendingRemoval, id]); const wrapperClassName = cn(toastWrapperVariants({ placement })); return ( {/* Animated toast instance */} {children} {/* When visible toasts have different heights, the toast adapts to the last visible toast height. In cases where a toast originally has one height and gets smaller when a new toast comes to stack, content might be visible behind the last toast without proper padding. The placeholder Views ensure that the content under active toast is hidden. */} {/* Hidden toast instance used to measure natural height on native. Web owns its own natural height (Reanimated's height-spring is disabled on web to avoid the enter/exit glitch), so the duplicate node is skipped — this halves the DOM node count per toast on web. */} {!IS_WEB && ( { const measuredHeight = event.nativeEvent.layout.height; heights.modify((value) => { 'worklet'; return { ...value, [id]: measuredHeight }; }); }} {...restProps} > {children} )} ); }); // -------------------------------------------------- const ToastTitle = forwardRef((props, ref) => { const { children, className, ...restProps } = props; const { nativeID } = useToastContext(); const { color } = useToastStyleContext(); const labelClassName = cn(toastLabelVariants({ color }), className); return ( {children} ); }); // -------------------------------------------------- const ToastDescription = forwardRef((props, ref) => { const { children, className, ...restProps } = props; const { nativeID } = useToastContext(); const { color } = useToastStyleContext(); const descriptionClassName = cn(toastDescriptionVariants({ color }), className); return ( {children} ); }); // -------------------------------------------------- const ToastIcon = ({ asChild, className, style, as, ...props }: ToastIconProps) => { const { color } = useToastStyleContext(); const computedClassName = cn(toastIconVariants({ color }), className); if (asChild) { return ( ); } if (!as) { return null; } return ( ); }; ToastIcon.displayName = 'Toast.Icon'; // -------------------------------------------------- const ToastAction = forwardRef((props, ref) => { const { children, variant, color, size = 'small', className, ...restProps } = props; const { color: toastColor } = useToastStyleContext(); // TODO: Add toastActionVariants to styles.ts to restore color-aware action styling const buttonColor = useButtonColor({ color, toastColor }); return ( ); }); // -------------------------------------------------- const ToastClose = forwardRef((props, ref) => { const { children, size = 'small', className, onPress, ...restProps } = props; const { hide, id } = useToastStyleContext(); /** * Handle close button press * If hide and id are available from context, use them to hide the toast * Otherwise, use the provided onPress handler */ const handlePress = (event: any) => { if (hide && id) { hide(id); } if (onPress && typeof onPress === 'function') { onPress(event); } }; return ( ); }); // -------------------------------------------------- /** * Default styled toast component for simplified toast.show() API * Used internally when showing toasts with string or config object (without component) */ export function DefaultToast(props: DefaultToastProps) { const globalConfig = useToastConfig(); const { id, color: localColor, placement: localPlacement, isSwipeable: localIsSwipeable, animation: localAnimation, label, description, actionLabel, onActionPress, icon, hide, show, ...toastComponentProps } = props; /** * Merge global config with local props, ensuring local props take precedence */ const rawDefaultColor = localColor ?? globalConfig?.color ?? 'neutral'; const placement = localPlacement ?? globalConfig?.placement ?? 'bottom'; const isSwipeable = localIsSwipeable ?? globalConfig?.isSwipeable; const animation = localAnimation ?? globalConfig?.animation; useEffect(() => { if (__DEV__ && rawDefaultColor === ('default' as string) && !hasWarnedToastDefaultColor) { hasWarnedToastDefaultColor = true; console.warn( '[Toast] The `default` color is deprecated and will be removed in a future major release. Use `neutral` instead.', ); } }, [rawDefaultColor]); const color: NonNullable = (rawDefaultColor as string) === 'default' ? 'neutral' : (rawDefaultColor as NonNullable); const handleActionPress = () => { if (onActionPress) { onActionPress({ show, hide }); } }; return ( {icon ? : null} {label && {label}} {description && {description}} {actionLabel && {actionLabel}} ); } // -------------------------------------------------- ToastRoot.displayName = 'Toast'; ToastTitle.displayName = 'Toast.Title'; ToastDescription.displayName = 'Toast.Description'; ToastIcon.displayName = 'Toast.Icon'; ToastAction.displayName = 'Toast.Action'; ToastClose.displayName = 'Toast.Close'; type ToastCompoundComponent = typeof ToastRoot & { Title: typeof ToastTitle; Description: typeof ToastDescription; Icon: typeof ToastIcon; Action: typeof ToastAction; Close: typeof ToastClose; }; export const Toast = Object.assign(ToastRoot, { Title: ToastTitle, Description: ToastDescription, Icon: ToastIcon, Action: ToastAction, Close: ToastClose, }) as ToastCompoundComponent; export type { ToastColor, ToastPlacement, ToastRootAnimation, ToastProps, ToastTitleProps, ToastDescriptionProps, ToastIconProps, ToastActionProps, ToastCloseProps, ToastContextValue, ToastRootRef, DefaultToastProps, } from './types';