diff --git a/src/components/ui/Toast.tsx b/src/components/ui/Toast.tsx new file mode 100644 index 0000000..9ca1692 --- /dev/null +++ b/src/components/ui/Toast.tsx @@ -0,0 +1,89 @@ +import React, { useEffect } from 'react'; +import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react'; +import { Toast, useToast, ToastType } from './ToastContext'; + +const DEFAULT_AUTO_DISMISS_DURATIONS: Record = { + success: 5000, + info: 5000, + warning: 5000, + error: 8000, +}; + +const TOAST_STYLES: Record = { + success: { + bg: 'bg-green-50', + text: 'text-green-800', + border: 'border-green-200', + icon: , + }, + error: { + bg: 'bg-red-50', + text: 'text-red-800', + border: 'border-red-200', + icon: , + }, + warning: { + bg: 'bg-amber-50', + text: 'text-amber-800', + border: 'border-amber-200', + icon: , + }, + info: { + bg: 'bg-blue-50', + text: 'text-blue-800', + border: 'border-blue-200', + icon: , + }, +}; + +const ToastItem: React.FC<{ toast: Toast }> = ({ toast }) => { + const { dismissToast } = useToast(); + const style = TOAST_STYLES[toast.type]; + + useEffect(() => { + const duration = toast.duration ?? DEFAULT_AUTO_DISMISS_DURATIONS[toast.type]; + const timer = setTimeout(() => { + dismissToast(toast.id); + }, duration); + + return () => clearTimeout(timer); + }, [toast.id, toast.type, toast.duration, dismissToast]); + + const role = (toast.type === 'error') ? 'alert' : 'status'; + + return ( +
+
{style.icon}
+
+

{toast.title}

+

{toast.message}

+
+ +
+ ); +}; + +export const ToastContainer: React.FC = () => { + const { activeToasts } = useToast(); + + if (activeToasts.length === 0) return null; + + return ( +
+
+ {activeToasts.map((toast) => ( + + ))} +
+
+ ); +}; diff --git a/src/components/ui/ToastContext.tsx b/src/components/ui/ToastContext.tsx new file mode 100644 index 0000000..873956e --- /dev/null +++ b/src/components/ui/ToastContext.tsx @@ -0,0 +1,73 @@ +import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'; + +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +export interface Toast { + id: string; + type: ToastType; + title: string; + message: string; + duration?: number; +} + +interface ToastContextType { + showToast: (toast: Omit) => void; + dismissToast: (id: string) => void; + activeToasts: Toast[]; +} + +const ToastContext = createContext(undefined); + +const MAX_VISIBLE_TOASTS = 3; +const DEFAULT_AUTO_DISMISS_DURATIONS: Record = { + success: 5000, + info: 5000, + warning: 5000, // Not explicitly specified, but 5s is a reasonable default + error: 8000, +}; + +export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [activeToasts, setActiveToasts] = useState([]); + const [toastQueue, setToastQueue] = useState[]>([]); + + // Use a ref for the queue to avoid stale state issues in fast-firing toasts + const queueRef = useRef[]>([]); + + const dismissToast = useCallback((id: string) => { + setActiveToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const showToast = useCallback((toast: Omit) => { + queueRef.current.push(toast); + setToastQueue([...queueRef.current]); + }, []); + + // Effect to move toasts from queue to active + useEffect(() => { + if (activeToasts.length < MAX_VISIBLE_TOASTS && toastQueue.length > 0) { + const nextToast = queueRef.current.shift()!; + setToastQueue([...queueRef.current]); + + const newToast: Toast = { + ...nextToast, + id: Math.random().toString(36).substring(2, 9), + }; + + setActiveToasts((prev) => [...prev, newToast]); + } + }, [activeToasts.length, toastQueue.length]); + + return ( + + {children} + + ); +}; + +export const useToast = () => { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +}; diff --git a/src/components/ui/__tests__/Toast.test.tsx b/src/components/ui/__tests__/Toast.test.tsx new file mode 100644 index 0000000..517361a --- /dev/null +++ b/src/components/ui/__tests__/Toast.test.tsx @@ -0,0 +1,115 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { ToastProvider, useToast } from '../ToastContext'; +import { ToastContainer } from '../Toast'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +const TestComponent = () => { + const { showToast } = useToast(); + return ( +
+ + + +
+ ); +}; + +const renderWithProvider = () => { + return render( + + + + + ); +}; + +describe('GlobalToastSystem', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders a success toast with correct role and auto-dismisses after 5s', async () => { + renderWithProvider(); + + fireEvent.click(screen.getByText('Show Success')); + + const toast = screen.getByRole('status'); + expect(toast).toBeInTheDocument(); + expect(screen.getByText('Success')).toBeInTheDocument(); + expect(screen.getByText('Success message')).toBeInTheDocument(); + + // Fast forward 5s + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(screen.queryByText('Success')).not.toBeInTheDocument(); + }); + + it('renders an error toast with role="alert" and auto-dismisses after 8s', async () => { + renderWithProvider(); + + fireEvent.click(screen.getByText('Show Error')); + + const toast = screen.getByRole('alert'); + expect(toast).toBeInTheDocument(); + + // Fast forward 5s (should still be there) + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(screen.getByText('Error')).toBeInTheDocument(); + + // Fast forward to 8s + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(screen.queryByText('Error')).not.toBeInTheDocument(); + }); + + it('manually dismisses a toast when clicking the close button', async () => { + renderWithProvider(); + + fireEvent.click(screen.getByText('Show Info')); + expect(screen.getByText('Info')).toBeInTheDocument(); + + const closeButton = screen.getByLabelText('Close'); + fireEvent.click(closeButton); + + expect(screen.queryByText('Info')).not.toBeInTheDocument(); + }); + + it('queues toasts when more than 3 are shown', async () => { + renderWithProvider(); + + // Show 5 toasts + fireEvent.click(screen.getByText('Show Success')); + fireEvent.click(screen.getByText('Show Success')); + fireEvent.click(screen.getByText('Show Success')); + fireEvent.click(screen.getByText('Show Error')); + fireEvent.click(screen.getByText('Show Info')); + + // Only 3 should be visible initially + const statusToasts = screen.queryAllByRole('status'); + const alertToasts = screen.queryAllByRole('alert'); + const allToasts = [...statusToasts, ...alertToasts]; + expect(allToasts).toHaveLength(3); + + // Dismiss one + const closeButtons = screen.getAllByLabelText('Close'); + fireEvent.click(closeButtons[0]); + + // A new one should appear from the queue + const statusToastsAfter = screen.queryAllByRole('status'); + const alertToastsAfter = screen.queryAllByRole('alert'); + const allToastsAfter = [...statusToastsAfter, ...alertToastsAfter]; + expect(allToastsAfter).toHaveLength(3); + + // Specifically check if the 4th one (Error) is now visible + expect(screen.getByText('Error')).toBeInTheDocument(); + }); +}); diff --git a/src/main.tsx b/src/main.tsx index dbfee2f..69f3202 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,6 +3,8 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import { QueryClientProvider } from '@tanstack/react-query' import { queryClient } from './lib/query-client' +import { ToastProvider } from './components/ui/ToastContext' +import { ToastContainer } from './components/ui/Toast' import './index.css' import App from './App.tsx' @@ -15,9 +17,12 @@ async function bootstrap() { createRoot(document.getElementById('root')!).render( - {/* 2. Wrap your App */} - - + + {/* 2. Wrap your App */} + + + + , )