From 2b1f37a2fe7b41bd18953fc2d53d31809458a0cb Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Thu, 16 Apr 2026 20:14:15 +0800 Subject: [PATCH] feat: toast notification system (bounty #825) - ToastProvider with success/error/warning/info variants - Auto-dismiss with countdown progress bar (configurable duration) - Slide-in animation from top-right - Stacks up to 5 toasts - Global showToast() function for use anywhere - SolFoundry dark forge theme with emerald/amber/red/cyan accents --- frontend/src/components/ui/Toast.tsx | 212 +++++++++++++++++++++++++++ frontend/src/main.tsx | 9 +- 2 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/ui/Toast.tsx diff --git a/frontend/src/components/ui/Toast.tsx b/frontend/src/components/ui/Toast.tsx new file mode 100644 index 000000000..d858887cf --- /dev/null +++ b/frontend/src/components/ui/Toast.tsx @@ -0,0 +1,212 @@ +'use client'; + +import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type ToastVariant = 'success' | 'error' | 'warning' | 'info'; + +export interface Toast { + id: string; + title: string; + description?: string; + variant: ToastVariant; + duration: number; +} + +interface ToastContextValue { + toasts: Toast[]; + addToast: (toast: Omit) => void; + removeToast: (id: string) => void; +} + +// ─── Context ───────────────────────────────────────────────────────────────── + +const ToastContext = createContext(null); + +export function useToast() { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within ToastProvider'); + return ctx; +} + +// ─── Variant Styles ─────────────────────────────────────────────────────────── + +const variantStyles: Record = { + success: { + bg: 'bg-forge-900', + border: 'border-emerald/30', + icon: '✓', + iconColor: 'text-emerald', + }, + error: { + bg: 'bg-forge-900', + border: 'border-status-error/30', + icon: '✕', + iconColor: 'text-status-error', + }, + warning: { + bg: 'bg-forge-900', + border: 'border-status-warning/30', + icon: '⚠', + iconColor: 'text-status-warning', + }, + info: { + bg: 'bg-forge-900', + border: 'border-status-info/30', + icon: 'ℹ', + iconColor: 'text-status-info', + }, +}; + +// ─── Individual Toast Item ───────────────────────────────────────────────────── + +function ToastItem({ toast, onRemove }: { toast: Toast; onRemove: (id: string) => void }) { + const { bg, border, icon, iconColor } = variantStyles[toast.variant]; + const [visible, setVisible] = useState(false); + const [progress, setProgress] = useState(100); + + useEffect(() => { + // Trigger entrance animation + requestAnimationFrame(() => setVisible(true)); + + const startTime = Date.now(); + const interval = setInterval(() => { + const elapsed = Date.now() - startTime; + const remaining = Math.max(0, 100 - (elapsed / toast.duration) * 100); + setProgress(remaining); + if (remaining === 0) { + clearInterval(interval); + handleRemove(); + } + }, 50); + + return () => clearInterval(interval); + }, [toast.duration]); + + const handleRemove = () => { + setVisible(false); + setTimeout(() => onRemove(toast.id), 300); + }; + + return ( +
+ {/* Progress bar */} +
+ +
+ {/* Icon */} + {icon} + + {/* Content */} +
+

{toast.title}

+ {toast.description && ( +

{toast.description}

+ )} +
+ + {/* Close button */} + +
+
+ ); +} + +// ─── Toast Container ────────────────────────────────────────────────────────── + +function ToastContainer({ toasts, onRemove }: { toasts: Toast[]; onRemove: (id: string) => void }) { + return ( +
+ {toasts.map((toast) => ( + + ))} +
+ ); +} + +// ─── Provider ───────────────────────────────────────────────────────────────── + +let toastIdCounter = 0; + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + const toastsRef = useRef(toasts); + toastsRef.current = toasts; + + const addToast = useCallback((toast: Omit) => { + const id = `toast-${++toastIdCounter}-${Date.now()}`; + setToasts((prev) => [...prev.slice(-4), { ...toast, id }]); // max 5 toasts + }, []); + + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + // Listen for global show-toast events + useEffect(() => { + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + addToast(detail); + }; + window.addEventListener('show-toast', handler); + return () => window.removeEventListener('show-toast', handler); + }, [addToast]); + + return ( + + {children} + + + ); +} + +// ─── Convenience functions (use via hook or import toast fn) ────────────────── + +export function showToast( + variant: ToastVariant, + title: string, + description?: string, + duration = 5000 +) { + // Dispatches a custom event that the ToastProvider listens to + window.dispatchEvent( + new CustomEvent('show-toast', { detail: { variant, title, description, duration } }) + ); +} + +export const toast = { + success: (title: string, description?: string, duration = 5000) => + showToast('success', title, description, duration), + error: (title: string, description?: string, duration = 5000) => + showToast('error', title, description, duration), + warning: (title: string, description?: string, duration = 5000) => + showToast('warning', title, description, duration), + info: (title: string, description?: string, duration = 5000) => + showToast('info', title, description, duration), +}; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index b20036806..bb603ae28 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -2,6 +2,7 @@ import React, { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { BrowserRouter } from 'react-router-dom'; import { QueryClientProvider } from '@tanstack/react-query'; +import { ToastProvider } from './components/ui/Toast'; import { AuthProvider } from './contexts/AuthContext'; import { queryClient } from './services/queryClient'; import App from './App'; @@ -14,9 +15,11 @@ createRoot(root).render( - - - + + + + +