Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions src/components/ui/Toast.tsx
Original file line number Diff line number Diff line change
@@ -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<ToastType, number> = {
success: 5000,
info: 5000,
warning: 5000,
error: 8000,
};

const TOAST_STYLES: Record<ToastType, { bg: string; text: string; icon: React.ReactNode; border: string }> = {
success: {
bg: 'bg-green-50',
text: 'text-green-800',
border: 'border-green-200',
icon: <CheckCircle className="w-5 h-5 text-green-500" />,
},
error: {
bg: 'bg-red-50',
text: 'text-red-800',
border: 'border-red-200',
icon: <AlertCircle className="w-5 h-5 text-red-500" />,
},
warning: {
bg: 'bg-amber-50',
text: 'text-amber-800',
border: 'border-amber-200',
icon: <AlertTriangle className="w-5 h-5 text-amber-500" />,
},
info: {
bg: 'bg-blue-50',
text: 'text-blue-800',
border: 'border-blue-200',
icon: <Info className="w-5 h-5 text-blue-500" />,
},
};

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 (
<div
role={role}
className={`flex items-start p-4 mb-4 rounded-lg border shadow-md transition-all duration-300 ease-in-out transform translate-x-0 ${style.bg} ${style.border} ${style.text}`}
>
<div className="flex-shrink-0 mr-3">{style.icon}</div>
<div className="flex-1">
<h3 className="text-sm font-semibold">{toast.title}</h3>
<p className="mt-1 text-sm opacity-90">{toast.message}</p>
</div>
<button
onClick={() => dismissToast(toast.id)}
className="flex-shrink-0 ml-4 p-1 rounded-md hover:bg-black/5 transition-colors"
aria-label="Close"
>
<X className="w-4 h-4" />
</button>
</div>
);
};

export const ToastContainer: React.FC = () => {
const { activeToasts } = useToast();

if (activeToasts.length === 0) return null;

return (
<div className="fixed bottom-4 right-4 z-[9999] w-full max-w-sm px-4 md:px-0">
<div className="flex flex-col gap-2">
{activeToasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} />
))}
</div>
</div>
);
};
73 changes: 73 additions & 0 deletions src/components/ui/ToastContext.tsx
Original file line number Diff line number Diff line change
@@ -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<Toast, 'id'>) => void;
dismissToast: (id: string) => void;
activeToasts: Toast[];
}

const ToastContext = createContext<ToastContextType | undefined>(undefined);

const MAX_VISIBLE_TOASTS = 3;
const DEFAULT_AUTO_DISMISS_DURATIONS: Record<ToastType, number> = {

Check failure on line 22 in src/components/ui/ToastContext.tsx

View workflow job for this annotation

GitHub Actions / validate

'DEFAULT_AUTO_DISMISS_DURATIONS' is assigned a value but never used
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<Toast[]>([]);
const [toastQueue, setToastQueue] = useState<Omit<Toast, 'id'>[]>([]);

// Use a ref for the queue to avoid stale state issues in fast-firing toasts
const queueRef = useRef<Omit<Toast, 'id'>[]>([]);

const dismissToast = useCallback((id: string) => {
setActiveToasts((prev) => prev.filter((t) => t.id !== id));
}, []);

const showToast = useCallback((toast: Omit<Toast, 'id'>) => {
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 (
<ToastContext.Provider value={{ showToast, dismissToast, activeToasts }}>
{children}
</ToastContext.Provider>
);
};

export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
115 changes: 115 additions & 0 deletions src/components/ui/__tests__/Toast.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<button onClick={() => showToast({ type: 'success', title: 'Success', message: 'Success message' })}>Show Success</button>
<button onClick={() => showToast({ type: 'error', title: 'Error', message: 'Error message' })}>Show Error</button>
<button onClick={() => showToast({ type: 'info', title: 'Info', message: 'Info message' })}>Show Info</button>
</div>
);
};

const renderWithProvider = () => {
return render(
<ToastProvider>
<TestComponent />
<ToastContainer />
</ToastProvider>
);
};

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();
});
});
11 changes: 8 additions & 3 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -15,9 +17,12 @@ async function bootstrap() {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter> {/* 2. Wrap your App */}
<App />
</BrowserRouter>
<ToastProvider>
<BrowserRouter> {/* 2. Wrap your App */}
<App />
</BrowserRouter>
<ToastContainer />
</ToastProvider>
</QueryClientProvider>
</StrictMode>,
)
Expand Down
Loading