'use client'; import * as React from 'react'; import { View, type LayoutChangeEvent } from 'react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { useToastProviderContext } from '../provider/ToastProviderContext'; import { useRenderElement } from '../../use-render/useRenderElement'; import { useIsoLayoutEffect } from '../../hooks/useIsoLayoutEffect'; import { useStableCallback } from '../../hooks/useStableCallback'; import { useRefWithInit } from '../../hooks/useRefWithInit'; import { AnimationFrame } from '../../hooks/useAnimationFrame'; import type { ZestUIComponentProps } from '../../types'; import type { ToastObject } from '../useToastManager'; import { ToastRootContext } from './ToastRootContext'; import { useStoreState } from '../../store/ReactStore'; /** Matches `Drawer.Popup`: a swipe dismisses once it has travelled this far. */ const DEFAULT_SWIPE_THRESHOLD = 40; export type ToastSwipeDirection = 'up' | 'down' | 'left' | 'right'; /** * Groups all parts of an individual toast. * Renders a `` wrapped in a `GestureDetector`. * * Follows the animation contract: it never moves itself. It publishes * `transitionStatus`, its stacking `offsetY`/`index`, and how far a swipe has * travelled, and the consumer turns those into a transform. * * **Enter:** `transitionStatus` starts as `'starting'` (auto-clears to * `undefined` after one frame). Key off `state.transitionStatus === 'starting'` * to trigger a slide-in or fade-in animation. * * **Exit:** `removeOnClose` (on by default) drops the toast the moment it * closes — the right default for a consumer who does not animate. To animate * the exit, set `removeOnClose={false}`, drive the exit from * `state.transitionStatus === 'ending'` using `state.measuredHeight` as the * starting value (the animation target is `0`), and call * `useToastManager().remove(id)` when the animation finishes. * This is the same lever as `Collapsible.Panel`'s `keepMounted`. */ export function ToastRoot(componentProps: ToastRoot.Props) { const { render, className, style, toast, removeOnClose = true, swipeDirection = 'right', swipeThreshold = DEFAULT_SWIPE_THRESHOLD, ref, ...elementProps } = componentProps; const { testID } = elementProps; const store = useToastProviderContext(); const [titleId, setTitleId] = React.useState(undefined); const [descriptionId, setDescriptionId] = React.useState(undefined); const [swiping, setSwiping] = React.useState(false); const [swipeMovement, setSwipeMovement] = React.useState(0); // The gesture callbacks read the movement they just published, which React has // not re-rendered yet by the time the release arrives. const swipeMovementRef = React.useRef(0); // A swipe reports its position on every gesture event, which can land several // times a frame. The ref is the synchronous truth; React state is committed at // most once per frame and flushed synchronously when the gesture ends. const movementFrame = useRefWithInit(AnimationFrame.create).current; const publishMovement = useStableCallback((movement: number) => { swipeMovementRef.current = movement; movementFrame.request(() => setSwipeMovement(swipeMovementRef.current)); }); const resetMovement = useStableCallback(() => { movementFrame.cancel(); swipeMovementRef.current = 0; setSwipeMovement(0); }); const index = useStoreState(store, 'toastIndex', toast.id); const offsetY = useStoreState(store, 'toastOffsetY', toast.id); const visibleIndex = useStoreState(store, 'toastVisibleIndex', toast.id); const expanded = useStoreState(store, 'expanded'); // Without an animation there is nothing to wait for, so a closed toast leaves // the list at once. useIsoLayoutEffect(() => { if (removeOnClose && toast.transitionStatus === 'ending') { store.removeToast(toast.id); } }, [removeOnClose, toast.transitionStatus, toast.id, store]); // `transitionStatus` starts as `'starting'` and stays that way forever. // Auto-clear it after one frame so the consumer can react to `'starting'` // for an enter animation without repeatedly re-triggering. useIsoLayoutEffect(() => { if (toast.transitionStatus === 'starting') { const frame = AnimationFrame.request(() => { store.updateToastInternal(toast.id, { transitionStatus: undefined }); }); return () => AnimationFrame.cancel(frame); } return undefined; }, [toast.transitionStatus, toast.id, store]); const handleLayout = useStableCallback((event: LayoutChangeEvent) => { const { height } = event.nativeEvent.layout; // A closing toast reports a height of 0 through the store; measuring it again // here would undo that and leave a gap in the stack. if (toast.transitionStatus !== 'ending' && height !== toast.height) { store.updateToastInternal(toast.id, { height, measuredHeight: height }); } }); const gesture = React.useMemo( () => Gesture.Pan() // A gesture is invisible to the rendered tree, so tests can only reach it // through gesture-handler's registry, which is keyed by this id. .withTestId(testID ?? `toast-${toast.id}`) .onBegin(() => { setSwiping(true); // A finger on the toast means "not yet" — the same intent hover // carries on the web, where upstream pairs these two exactly like this. // Setting `pressed` alone would only re-render; the timers keep running // until they are told to stop. store.set('pressed', true); store.pauseTimers(); }) .onStart((event) => publishMovement(getDisplacement(swipeDirection, event))) .onUpdate((event) => publishMovement(getDisplacement(swipeDirection, event))) .onEnd((event) => { if (getDisplacement(swipeDirection, event) > swipeThreshold) { store.closeToast(toast.id); } }) .onFinalize(() => { setSwiping(false); resetMovement(); store.set('pressed', false); // `expandedOrInactive`, not `expanded`: letting go while the app is in // the background must not restart the countdown, or the toast spends it // where nobody can see it. Upstream guards the same way, on the window // being focused. if (!store.select('expandedOrInactive')) { store.resumeTimers(); } }) // The handlers touch React state, so they must not run on the UI thread. .runOnJS(true), [testID, toast.id, swipeDirection, swipeThreshold, store, publishMovement, resetMovement], ); const state: ToastRootState = React.useMemo( () => ({ transitionStatus: toast.transitionStatus, expanded, limited: toast.limited ?? false, type: toast.type, index, visibleIndex, offsetY, height: toast.height ?? 0, measuredHeight: toast.measuredHeight ?? toast.height ?? 0, swiping, swipeMovement, swipeDirection, }), [ toast.transitionStatus, expanded, toast.limited, toast.type, index, visibleIndex, offsetY, toast.height, toast.measuredHeight, swiping, swipeMovement, swipeDirection, ], ); const contextValue: ToastRootContext = React.useMemo( () => ({ toast, state, titleId, setTitleId, descriptionId, setDescriptionId }), [toast, state, titleId, descriptionId], ); const element = useRenderElement(View, componentProps, { state, ref, props: [ { onLayout: handleLayout, accessibilityRole: 'alert' as const, accessibilityLiveRegion: (toast.priority === 'high' ? 'assertive' : 'polite') as | 'assertive' | 'polite', accessibilityLabelledBy: titleId, 'aria-labelledby': titleId, 'aria-describedby': descriptionId, }, elementProps, ], }); return ( {element} ); } /** How far a translation has travelled towards `direction`. */ function getDisplacement( direction: ToastSwipeDirection, event: { translationX: number; translationY: number }, ) { switch (direction) { case 'up': return -event.translationY; case 'down': return event.translationY; case 'left': return -event.translationX; default: return event.translationX; } } export interface ToastRootState { /** * The toast's transition status: `'starting'` as it arrives (auto-clears to * `undefined` after one frame for enter animation), `'ending'` once it is * closing. */ transitionStatus: 'starting' | 'ending' | undefined; /** * Whether a toast is being pressed or focused, which pauses the timers. */ expanded: boolean; /** * Whether the toast is beyond the provider's `limit`. Limited toasts stay in * the list so they can be hidden or animated out rather than vanishing. */ limited: boolean; /** * The toast's type, as passed to `add()`. */ type: string | undefined; /** * The toast's index in the list, newest first. */ index: number; /** * The toast's index among the toasts that are not closing, or `-1` when it is. */ visibleIndex: number; /** * How far down the stack the toast sits, in pixels: the total height of every * toast before it. This is the RN counterpart of upstream's * `--toast-offset-y` CSS variable. */ offsetY: number; /** * The toast's measured height. */ height: number; /** * The last measured height before the toast closed, preserved for the * consumer to use as the starting value for an exit animation. * Falls back to `height` when no close has occurred yet. */ measuredHeight: number; /** * Whether a swipe is currently in progress. */ swiping: boolean; /** * How far the current swipe has travelled towards `swipeDirection`, in pixels. */ swipeMovement: number; /** * The direction a swipe must travel to dismiss the toast. */ swipeDirection: ToastSwipeDirection; } export interface ToastRootProps extends ZestUIComponentProps { /** * The toast to render, from `useToastManager().toasts`. */ toast: ToastObject; /** * Whether to remove the toast from the list as soon as it closes. * * Set it to `false` to animate the exit: drive the animation from * `state.transitionStatus === 'ending'` and call `useToastManager().remove(id)` * when it finishes. Nothing in React Native can report that for you. * @default true */ removeOnClose?: boolean | undefined; /** * The swipe direction used to dismiss the toast. * @default 'right' */ swipeDirection?: ToastSwipeDirection | undefined; /** * How far a swipe must travel, in pixels, before it dismisses the toast. * @default 40 */ swipeThreshold?: number | undefined; } export namespace ToastRoot { export type State = ToastRootState; export type Props = ToastRootProps; export type SwipeDirection = ToastSwipeDirection; }