'use client'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { store } from '../core/store'; import type { RichColorsMode, ToastAction, ToastIcons, ToastRecord, ToastSize, ToasterProps, SwipeDirection, } from '../core/types'; import { CloseIcon, ErrorIcon, InfoIcon, LoadingIcon, SuccessIcon, WarningIcon } from './icons'; /** Config the Toaster resolves once and hands to every toast. */ export interface ToastRuntime { closeButton: boolean; showProgressBar: boolean; size: ToastSize; richColors: RichColorsMode | false; color: boolean; invert: boolean; expanded: boolean; swipeDirections: SwipeDirection[]; swipeThreshold: number; icons: ToastIcons; loadingIcon: React.ReactNode; defaults: ToasterProps['toastOptions']; closeButtonAriaLabel: string; closeOnClick: boolean; } interface ToastProps { toast: ToastRecord; /** 0 = front-most. */ index: number; /** Distance from the front, used for the collapsed stack scale. */ offset: number; removed: boolean; runtime: ToastRuntime; onHeight: (id: string, height: number) => void; /** Called when this toast held focus as it went away. */ onRequestRefocus: () => void; } const cx = (...parts: (string | false | undefined)[]) => parts.filter(Boolean).join(' '); const builtinIcon = (type: string, icons: ToastIcons, loadingIcon: React.ReactNode) => { switch (type) { case 'success': return icons.success ?? ; case 'error': return icons.error ?? ; case 'info': return icons.info ?? ; case 'warning': return icons.warning ?? ; case 'loading': return icons.loading ?? loadingIcon ?? ; default: return null; } }; export const Toast: React.FC = ({ toast, index, offset, removed, runtime, onHeight, onRequestRefocus, }) => { const ref = useRef(null); const [mounted, setMounted] = useState(false); const [swipe, setSwipe] = useState<{ x: number; y: number } | null>(null); const [swiping, setSwiping] = useState(false); const dragStart = useRef<{ x: number; y: number } | null>(null); const [inputValue, setInputValue] = useState(toast.input?.defaultValue || ''); const all = runtime.defaults || {}; // Same precedence as the store: toast > per-type default > global default. const defaults = { ...all, ...(all.types ? all.types[toast.type] : undefined) }; const classNames = { ...defaults.classNames, ...toast.classNames }; const dismissible = toast.dismissible ?? defaults.dismissible ?? true; const closeButton = toast.closeButton ?? toast.showCloseButton ?? defaults.closeButton ?? runtime.closeButton; const showProgressBar = toast.showProgressBar ?? defaults.showProgressBar ?? runtime.showProgressBar; const size: ToastSize = toast.size ?? defaults.size ?? runtime.size; const unstyled = toast.unstyled ?? defaults.unstyled ?? false; const invert = toast.invert ?? defaults.invert ?? runtime.invert; const richRaw = toast.richColors ?? defaults.richColors ?? runtime.richColors; const richColors: RichColorsMode | false = richRaw === true ? 'minimal' : richRaw === false || richRaw === undefined ? false : richRaw; const style = { ...defaults.style, ...defaults.customStyles, ...toast.customStyles, ...toast.style }; const actions: ToastAction[] = toast.actions ? toast.actions : toast.action ? [toast.action] : []; const isLoading = toast.type === 'loading' || toast.status === 'loading'; // `expandable` toasts keep their actions collapsed until the user opens them. const [selfExpanded, setSelfExpanded] = useState(false); const expandable = toast.expandable === true; const isOpen = expandable ? toast.expanded ?? selfExpanded : true; const toggle = () => { if (expandable && toast.expanded === undefined) setSelfExpanded((open) => !open); }; const hasCountdown = showProgressBar && toast.progress === undefined && isFinite(toast.duration) && toast.duration > 0; // Report height so the Toaster can compute real stack offsets rather than // guessing from content shape. useEffect(() => { const element = ref.current; if (!element) return; const report = () => onHeight(toast.id, element.getBoundingClientRect().height); report(); const Observer = typeof ResizeObserver !== 'undefined' ? ResizeObserver : null; if (!Observer) return; const observer = new Observer(report); observer.observe(element); return () => observer.disconnect(); }, [toast.id, toast.content, toast.description, toast.expanded, onHeight]); useEffect(() => { const frame = setTimeout(() => setMounted(true), 0); return () => clearTimeout(frame); }, []); const dismiss = useCallback(() => { // Check before removal: afterwards this element is on its way out and // focus would silently fall back to . const held = typeof document !== 'undefined' && !!ref.current && ref.current.contains(document.activeElement); store.dismiss(toast.id); if (held) onRequestRefocus(); }, [toast.id, onRequestRefocus]); const handleClose = useCallback(() => { if (!dismissible) return; dismiss(); }, [dismiss, dismissible]); const runAction = (action: ToastAction, event: React.MouseEvent) => { event.stopPropagation(); action.onClick(event); if (action.dismiss !== false) dismiss(); }; const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === 'Escape') { handleClose(); return; } if (event.key !== 'Enter' && event.key !== ' ') return; if (!toast.onClick && !expandable) return; event.preventDefault(); toggle(); toast.onClick?.(event as unknown as React.MouseEvent); }; const shouldCloseOnClick = (toast.closeOnClick ?? defaults.closeOnClick ?? runtime.closeOnClick) && dismissible; const handleClick = (event: React.MouseEvent) => { toggle(); toast.onClick?.(event); // Buttons inside the toast stop propagation, so this only fires for the body. if (shouldCloseOnClick) dismiss(); }; // Pointer-driven swipe. Cheaper than a gesture library and lets us keep // swiping enabled on toasts that also have buttons. const onPointerDown = (event: React.PointerEvent) => { if (!dismissible || !runtime.swipeDirections.length) return; if ((event.target as HTMLElement).closest('button,input,a,textarea,select')) return; dragStart.current = { x: event.clientX, y: event.clientY }; setSwiping(true); // Without capture, releasing outside the toast never fires pointerup here // and the toast stays stuck mid-swipe. const element = event.currentTarget; if (typeof element.setPointerCapture === 'function') { try { element.setPointerCapture(event.pointerId); } catch { /* some pointer types refuse capture; the swipe still works */ } } }; const onPointerMove = (event: React.PointerEvent) => { const start = dragStart.current; if (!start) return; const dx = event.clientX - start.x; const dy = event.clientY - start.y; const allowed = runtime.swipeDirections; setSwipe({ x: (dx < 0 && allowed.includes('left')) || (dx > 0 && allowed.includes('right')) ? dx : 0, y: (dy < 0 && allowed.includes('up')) || (dy > 0 && allowed.includes('down')) ? dy : 0, }); }; const endSwipe = () => { const moved = swipe; dragStart.current = null; setSwiping(false); setSwipe(null); if (!moved) return; const element = ref.current; const width = element ? element.offsetWidth || 320 : 320; const height = element ? element.offsetHeight || 64 : 64; if ( Math.abs(moved.x) > width * runtime.swipeThreshold || Math.abs(moved.y) > height * runtime.swipeThreshold ) { handleClose(); } }; const submitInput = (event: React.FormEvent) => { event.preventDefault(); const value = inputValue; if (!toast.input) return; if (!toast.input.allowEmpty && !value.trim()) return; toast.input.onSubmit(value); setInputValue(''); dismiss(); }; if (toast.customComponent) { const Custom = toast.customComponent; return (
  • ); } const icon = toast.icon ?? builtinIcon(toast.type, runtime.icons, runtime.loadingIcon); const interactive = Boolean(toast.onClick) || expandable || shouldCloseOnClick; return (
  • {toast.jsx !== undefined ? ( typeof toast.jsx === 'function' ? toast.jsx(toast.id) : toast.jsx ) : ( <> {icon ? (
    {icon}
    ) : null}
    {toast.content}
    {toast.description !== undefined && toast.description !== null ? (
    {toast.description}
    ) : null} {toast.input ? (
    setInputValue(event.target.value)} onFocus={() => store.pause()} onBlur={() => store.resume()} />
    ) : null} {(actions.length || toast.cancel) && isOpen ? (
    {actions.map((action, position) => ( ))} {toast.cancel ? ( ) : null}
    ) : null}
    {closeButton && dismissible ? ( ) : null} {showProgressBar && (hasCountdown || toast.progress !== undefined) ? (
    ) : null} )}
  • ); };