// Copyright (c) Meta Platforms, Inc. and affiliates. 'use client'; /** * @file Toast.tsx * @input Uses React timers, touch/pen gesture events, Toast options, Button/Icon, * MediaTheme, tokens, and placement motion inherited from ToastViewport * @output Exports the rendered Toast surface and its pause/swipe/dismiss behavior * @position Core implementation; rendered by ToastViewport and documented by Toast.doc.mjs * * SYNC: When Toast layout, timer pause, media theme, or dismissal behavior changes, * update these files to stay in sync: * - /packages/core/src/Toast/ToastViewport.test.tsx * - /packages/core/src/Toast/Toast.doc.mjs * - /apps/storybook/stories/Toast.stories.tsx * - /packages/cli/assets/templates/blocks/components/Toast/ (showcase blocks) */ import { useCallback, useEffect, useRef } from 'react'; import type { ReactNode } from 'react'; import * as stylex from "../style-runtime.js"; import { Button } from "../Button/index.js"; import { Icon } from "../Icon/index.js"; import "../theme/tokens.stylex.js"; import { colorVars, spacingVars, radiusVars, durationVars, easeVars, shadowVars, typographyVars, typeScaleDefaults } from "../theme/tokens.stylex.js"; import { mergeProps } from "../utils/index.js"; import { INTERACTIVE_SELECTORS } from "../hooks/useClickableContainer.js"; import { useTheme } from "../theme/index.js"; import { MediaTheme } from "../theme/MediaTheme.js"; import type { ToastType, ToastDismissReason, ToastContentRenderFn } from "./types.js"; import { themeProps } from "../utils/themeProps.js"; import { useTranslator } from "../i18n/index.js"; import { useToastGesture, type ToastGestureDirection } from "./useToastGesture.js"; const SWIPE_INTERACTIVE_TARGET_SELECTOR = `${INTERACTIVE_SELECTORS},[tabindex],[contenteditable]:not([contenteditable="false"])`; function isInteractiveTarget(target: EventTarget | null, root: HTMLElement): boolean { if (!(target instanceof Element)) { return false; } let current: Element | null = target; while (current != null && current !== root && current !== document.body) { if (current.matches(SWIPE_INTERACTIVE_TARGET_SELECTOR)) { return true; } current = current.parentElement; } return false; } const TOAST_EDGE_DRIFT = spacingVars['--spacing-2']; export interface ToastProps { type: ToastType; body: ReactNode; endContent?: ReactNode; isAutoHide: boolean; autoHideDuration: number; isExiting?: boolean; onDismiss: (reason: ToastDismissReason) => void; /** * Replaces the content of this toast's card with your own layout. Direct * `Toast` renders use the same contract as `ToastOptions.renderContent`; * apps normally set it per toast in the options passed to `useToast()`. */ renderContent?: ToastContentRenderFn; } interface ToastSurfaceProps extends ToastProps { gestureDirection: ToastGestureDirection; } /** * Individual toast notification. * * Renders with inverted surface colors for the default variant, * and error-inverted for the error variant. Applies MediaTheme for that * surface, unless the painted colors make the chosen side unreadable — * a theme is free to define an "inverted" background that is not. * Pauses auto-dismiss on hover and focus. * * @example * ``` * removeToast(id, reason)} * /> * ``` */ export function Toast(props: ToastProps) { return ; } export function ToastSurface({ type, body, endContent, isAutoHide, autoHideDuration, isExiting = false, onDismiss, renderContent, gestureDirection }: ToastSurfaceProps) { const t = useTranslator(); const timerRef = useRef | null>(null); const isPausedRef = useRef(false); const remainingRef = useRef(autoHideDuration); // Will be initialized by startTimer when actually used const startTimeRef = useRef(null); // Read onDismiss through a ref: the viewport re-creates it on every render // (another toast arriving/exiting), and a startTimer that depends on it // would restart — and un-pause — this toast's timer on unrelated renders. const onDismissRef = useRef(onDismiss); onDismissRef.current = onDismiss; const startTimer = useCallback(() => { if (!isAutoHide || isPausedRef.current) { return; } if (timerRef.current) { clearTimeout(timerRef.current); } startTimeRef.current = Date.now(); timerRef.current = setTimeout(() => { onDismissRef.current('auto'); }, remainingRef.current); }, [isAutoHide]); const pauseTimer = useCallback(() => { if (!isAutoHide || isPausedRef.current) { return; } isPausedRef.current = true; if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } if (startTimeRef.current != null) { const elapsed = Date.now() - startTimeRef.current; remainingRef.current = Math.max(remainingRef.current - elapsed, 1000); } }, [isAutoHide]); const resumeTimer = useCallback(() => { if (!isAutoHide || !isPausedRef.current) { return; } isPausedRef.current = false; startTimer(); }, [isAutoHide, startTimer]); useEffect(() => { remainingRef.current = autoHideDuration; startTimer(); return () => { if (timerRef.current) { clearTimeout(timerRef.current); } }; // startTimer's identity is stable per isAutoHide, so this runs on mount // and on a genuine duration change — not on unrelated viewport renders. }, [autoHideDuration, startTimer]); // Pause the auto-hide timer while the window is not focused, so a toast // doesn't silently expire while the user is in another window or tab. useEffect(() => { if (!isAutoHide) { return; } window.addEventListener('blur', pauseTimer); window.addEventListener('focus', resumeTimer); return () => { window.removeEventListener('blur', pauseTimer); window.removeEventListener('focus', resumeTimer); }; }, [isAutoHide, pauseTimer, resumeTimer]); const isTimerPaused = useCallback(() => isPausedRef.current, []); const dismissFromGesture = useCallback(() => { onDismissRef.current('manual'); }, []); const { rootRef, bindings: gestureBindings } = useToastGesture({ direction: gestureDirection, enabled: !isExiting, canPauseTimer: isAutoHide, isTimerPaused, pauseTimer, resumeTimer, dismiss: dismissFromGesture, shouldIgnoreTarget: isInteractiveTarget }); const handleDismiss = useCallback(() => { onDismiss('manual'); }, [onDismiss]); const isError = type === 'error'; // The surface is *usually* dark in light mode and light in dark mode, but a // theme can define --color-background-inverted as anything — so the mode is // measured, not assumed. This is only the pre-measurement fallback. const { mode } = useTheme(); const fallbackMediaMode = isError || mode === 'light' ? 'dark' : 'light'; return
{renderContent ? renderContent({ body, endContent, type, isAutoHide, autoHideDuration, dismiss: handleDismiss }) :
{body}
{endContent}
}
; } Toast.displayName = 'Toast'; ToastSurface.displayName = 'ToastSurface';