Skip to content

Commit 97c76c7

Browse files
committed
GlobalToastSystem
1 parent 0454a0c commit 97c76c7

File tree

2 files changed

+169
-1
lines changed

2 files changed

+169
-1
lines changed

src/app/layout.tsx

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

0 commit comments

Comments
 (0)