Skip to content

Commit 9b41fa4

Browse files
authored
Merge pull request #48 from UTDNebula/NotificationSystem
Global Toast Notification system
2 parents 2d52b17 + 6ad14e6 commit 9b41fa4

File tree

2 files changed

+196
-1
lines changed

2 files changed

+196
-1
lines changed

src/app/layout.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { type Metadata } from 'next';
77
import { GoogleAnalytics } from '@next/third-parties/google';
88

99
import theme from '@src/utils/theme';
10+
import { ToastProvider } from '@src/components/toast/ToastProvider';
1011

1112
const inter = Inter({
1213
subsets: ['latin'],
@@ -54,7 +55,9 @@ export default function RootLayout({
5455
className={`bg-white dark:bg-black ${inter.variable} font-main ${baiJamjuree.variable} text-haiti dark:text-white`}
5556
>
5657
<AppRouterCacheProvider>
57-
<ThemeProvider theme={theme}>{children}</ThemeProvider>
58+
<ThemeProvider theme={theme}>
59+
<ToastProvider>{children}</ToastProvider>
60+
</ThemeProvider>
5861
</AppRouterCacheProvider>
5962
{process.env.NEXT_PUBLIC_VERCEL_ENV === 'production' && (
6063
<GoogleAnalytics gaId="G-3NDS0P32CZ" />
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
'use client';
2+
import React, {
3+
createContext,
4+
useCallback,
5+
useContext,
6+
useMemo,
7+
useRef,
8+
useState,
9+
} from 'react';
10+
11+
// toast types (style variants)
12+
type ToastVariant = 'success' | 'error' | 'info' | 'warning';
13+
14+
// structure for each toast item
15+
export type Toast = {
16+
id: string;
17+
title?: string;
18+
message: string;
19+
variant?: ToastVariant;
20+
duration?: number; // milliseconds visible
21+
};
22+
23+
// methods exposed by the toast context
24+
type ToastContextValue = {
25+
show: (t: Omit<Toast, 'id'>) => string;
26+
dismiss: (id: string) => void;
27+
success: (
28+
message: string,
29+
opts?: Omit<Toast, 'id' | 'message' | 'variant'>,
30+
) => string;
31+
error: (
32+
message: string,
33+
opts?: Omit<Toast, 'id' | 'message' | 'variant'>,
34+
) => string;
35+
info: (
36+
message: string,
37+
opts?: Omit<Toast, 'id' | 'message' | 'variant'>,
38+
) => string;
39+
warning: (
40+
message: string,
41+
opts?: Omit<Toast, 'id' | 'message' | 'variant'>,
42+
) => string;
43+
};
44+
45+
const ToastContext = createContext<ToastContextValue | null>(null);
46+
47+
// generate simple random id
48+
const makeId = () =>
49+
Math.random().toString(36).slice(2) + Date.now().toString(36);
50+
51+
// provider wraps app and manages toast state
52+
export function ToastProvider({ children }: { children: React.ReactNode }) {
53+
const [toasts, setToasts] = useState<Toast[]>([]);
54+
const timers = useRef<Record<string, number>>({});
55+
56+
// remove toast and clear its timer
57+
const dismiss = useCallback((id: string) => {
58+
setToasts((prev) => prev.filter((t) => t.id !== id));
59+
const handle = timers.current[id];
60+
if (handle) {
61+
window.clearTimeout(handle);
62+
delete timers.current[id];
63+
}
64+
}, []);
65+
66+
// create and display a new toast
67+
const show = useCallback(
68+
({
69+
title,
70+
message,
71+
variant = 'info',
72+
duration = 4000,
73+
}: Omit<Toast, 'id'>) => {
74+
const id = makeId();
75+
const toast: Toast = { id, title, message, variant, duration };
76+
setToasts((prev) => [toast, ...prev]);
77+
timers.current[id] = window.setTimeout(() => dismiss(id), duration);
78+
return id;
79+
},
80+
[dismiss],
81+
);
82+
83+
// quick helpers for each variant (success, error, etc.)
84+
const factory =
85+
(variant: ToastVariant) =>
86+
(message: string, opts?: Omit<Toast, 'id' | 'message' | 'variant'>) =>
87+
show({ message, variant, ...opts });
88+
89+
// memoized context value
90+
const value = useMemo<ToastContextValue>(
91+
() => ({
92+
show,
93+
dismiss,
94+
success: factory('success'),
95+
error: factory('error'),
96+
info: factory('info'),
97+
warning: factory('warning'),
98+
}),
99+
[show, dismiss],
100+
);
101+
102+
return (
103+
<ToastContext.Provider value={value}>
104+
{children}
105+
<ToastViewport toasts={toasts} onDismiss={dismiss} />
106+
</ToastContext.Provider>
107+
);
108+
}
109+
110+
// hook to use inside components
111+
export function useToast() {
112+
const ctx = useContext(ToastContext);
113+
if (!ctx) {
114+
throw new Error('useToast must be used within <ToastProvider>');
115+
}
116+
return ctx;
117+
}
118+
119+
// renders list of active toasts
120+
function ToastViewport({
121+
toasts,
122+
onDismiss,
123+
}: {
124+
toasts: Toast[];
125+
onDismiss: (id: string) => void;
126+
}) {
127+
return (
128+
<div
129+
className="pointer-events-none fixed top-4 right-4 z-[1000] flex max-w-md flex-col gap-3"
130+
aria-live="polite"
131+
role="region"
132+
aria-label="Notifications"
133+
>
134+
{toasts.map((t) => (
135+
<ToastCard key={t.id} toast={t} onDismiss={() => onDismiss(t.id)} />
136+
))}
137+
</div>
138+
);
139+
}
140+
141+
// single toast card UI
142+
function ToastCard({
143+
toast,
144+
onDismiss,
145+
}: {
146+
toast: Toast;
147+
onDismiss: () => void;
148+
}) {
149+
const { title, message, variant = 'info' } = toast;
150+
151+
// variant-specific styling
152+
const styles: Record<ToastVariant, string> = {
153+
success:
154+
'border-green-200 bg-green-50 text-green-900 dark:border-green-900/30 dark:bg-green-950 dark:text-green-100',
155+
error:
156+
'border-red-200 bg-red-50 text-red-900 dark:border-red-900/30 dark:bg-red-950 dark:text-red-100',
157+
info: 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900/30 dark:bg-blue-950 dark:text-blue-100',
158+
warning:
159+
'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-900/30 dark:bg-amber-950 dark:text-amber-100',
160+
};
161+
162+
const badge: Record<ToastVariant, string> = {
163+
success: '✓',
164+
error: '✕',
165+
info: 'ℹ',
166+
warning: '⚠',
167+
};
168+
169+
return (
170+
<div
171+
className={`pointer-events-auto w-full rounded-2xl border p-4 shadow-lg backdrop-blur-sm ${styles[variant]}`}
172+
role={variant === 'error' ? 'alert' : 'status'}
173+
aria-live={variant === 'error' ? 'assertive' : 'polite'}
174+
>
175+
<div className="flex items-start gap-3">
176+
<div className="mt-0.5 text-xl leading-none">{badge[variant]}</div>
177+
<div className="flex-1">
178+
{title && <div className="font-semibold">{title}</div>}
179+
<div className="text-sm opacity-90">{message}</div>
180+
</div>
181+
<button
182+
type="button"
183+
onClick={onDismiss}
184+
className="ml-2 inline-flex h-7 w-7 items-center justify-center rounded-full text-sm hover:opacity-80 focus:ring-2 focus:ring-offset-2 focus:outline-none"
185+
aria-label="Dismiss notification"
186+
>
187+
188+
</button>
189+
</div>
190+
</div>
191+
);
192+
}

0 commit comments

Comments
 (0)