web/app/components/ui/toast.tsxblame
View source
4dfd09b1"use client";
4dfd09b2
4dfd09b3import {
4dfd09b4 createContext,
4dfd09b5 useContext,
4dfd09b6 useState,
4dfd09b7 useCallback,
4dfd09b8 useEffect,
4dfd09b9} from "react";
4dfd09b10import { createPortal } from "react-dom";
4dfd09b11
4dfd09b12interface ToastItem {
4dfd09b13 id: string;
4dfd09b14 message: string;
4dfd09b15 variant: "success" | "error" | "info";
4dfd09b16}
4dfd09b17
4dfd09b18interface ToastContextValue {
4dfd09b19 toast: (message: string, variant?: ToastItem["variant"]) => void;
4dfd09b20}
4dfd09b21
4dfd09b22const ToastContext = createContext<ToastContextValue>({
4dfd09b23 toast: () => {},
4dfd09b24});
4dfd09b25
4dfd09b26export function useToast() {
4dfd09b27 return useContext(ToastContext);
4dfd09b28}
4dfd09b29
4dfd09b30const variantStyles: Record<
4dfd09b31 ToastItem["variant"],
4dfd09b32 { bg: string; border: string; text: string }
4dfd09b33> = {
4dfd09b34 success: {
4dfd09b35 bg: "var(--status-open-bg)",
4dfd09b36 border: "var(--status-open-border)",
4dfd09b37 text: "var(--status-open-text)",
4dfd09b38 },
4dfd09b39 error: {
4dfd09b40 bg: "var(--error-bg)",
4dfd09b41 border: "var(--error-border)",
4dfd09b42 text: "var(--error-text)",
4dfd09b43 },
4dfd09b44 info: {
4dfd09b45 bg: "var(--accent-subtle)",
4dfd09b46 border: "var(--accent)",
4dfd09b47 text: "var(--accent)",
4dfd09b48 },
4dfd09b49};
4dfd09b50
4dfd09b51export function ToastProvider({ children }: { children: React.ReactNode }) {
4dfd09b52 const [toasts, setToasts] = useState<ToastItem[]>([]);
4dfd09b53 const [mounted, setMounted] = useState(false);
4dfd09b54
4dfd09b55 useEffect(() => {
4dfd09b56 setMounted(true);
4dfd09b57 }, []);
4dfd09b58
4dfd09b59 const toast = useCallback(
4dfd09b60 (message: string, variant: ToastItem["variant"] = "info") => {
4dfd09b61 const id = Math.random().toString(36).slice(2);
4dfd09b62 setToasts((prev) => [...prev, { id, message, variant }]);
4dfd09b63 setTimeout(() => {
4dfd09b64 setToasts((prev) => prev.filter((t) => t.id !== id));
4dfd09b65 }, 3000);
4dfd09b66 },
4dfd09b67 []
4dfd09b68 );
4dfd09b69
4dfd09b70 return (
4dfd09b71 <ToastContext.Provider value={{ toast }}>
4dfd09b72 {children}
4dfd09b73 {mounted &&
4dfd09b74 createPortal(
4dfd09b75 <div
4dfd09b76 className="fixed z-50"
4dfd09b77 style={{ bottom: 16, right: 16, display: "flex", flexDirection: "column", gap: 8 }}
4dfd09b78 >
4dfd09b79 {toasts.map((t) => {
4dfd09b80 const s = variantStyles[t.variant];
4dfd09b81 return (
4dfd09b82 <div
4dfd09b83 key={t.id}
4dfd09b84 className="toast-enter text-sm px-4 py-3"
4dfd09b85 style={{
4dfd09b86 backgroundColor: s.bg,
4dfd09b87 border: `1px solid ${s.border}`,
4dfd09b88 color: s.text,
4dfd09b89 minWidth: 200,
4dfd09b90 maxWidth: 360,
4dfd09b91 }}
4dfd09b92 >
4dfd09b93 {t.message}
4dfd09b94 </div>
4dfd09b95 );
4dfd09b96 })}
4dfd09b97 </div>,
4dfd09b98 document.body
4dfd09b99 )}
4dfd09b100 </ToastContext.Provider>
4dfd09b101 );
4dfd09b102}