/** Interruptible toast for completion and error feedback. */ import { animate } from "motion"; import { createContext } from "preact"; import { useCallback, useContext, useEffect, useRef, useState } from "preact/hooks"; import type { ComponentChildren } from "preact"; import { prefersReducedMotion, springs } from "../motion/springs"; type ToastKind = "info" | "error"; type ToastState = { id: number; message: string; kind: ToastKind; } | null; type ToastApi = { show: (message: string, kind?: ToastKind) => void; }; const ToastContext = createContext(null); export function useToast(): ToastApi { const ctx = useContext(ToastContext); if (!ctx) throw new Error("useToast requires ToastProvider"); return ctx; } export function ToastProvider({ children }: { children: ComponentChildren }) { const [toast, setToast] = useState(null); const elRef = useRef(null); const timer = useRef(null); const show = useCallback((message: string, kind: ToastKind = "info") => { setToast({ id: Date.now(), message, kind }); }, []); useEffect(() => { if (!toast || !elRef.current) return; const el = elRef.current; if (prefersReducedMotion()) { el.style.opacity = "1"; } else { el.style.opacity = "0"; el.style.transform = "translateY(-12px)"; animate(el, { opacity: 1, y: 0 }, springs.snap); } if (timer.current) window.clearTimeout(timer.current); timer.current = window.setTimeout(() => { if (prefersReducedMotion()) { setToast(null); return; } animate(el, { opacity: 0, y: -8 }, springs.press).finished.then(() => { setToast(null); }); }, 2200); return () => { if (timer.current) window.clearTimeout(timer.current); }; }, [toast]); return ( {children}
{toast ? (
{toast.message}
) : null}
); }