@marv3l/canopy-ui 1.0.4 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +1 -1
- package/templates/toast/toast.tsx +206 -61
- package/templates/toast/use-toast.ts +304 -175
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ A modern npm package for building beautiful, customizable user interfaces.
|
|
|
8
8
|
|
|
9
9
|
<br />
|
|
10
10
|
|
|
11
|
-
[](https://www.npmjs.com/package/canopy-ui)
|
|
11
|
+
[](https://www.npmjs.com/package/@marv3l/canopy-ui)
|
|
12
12
|
[](https://www.typescriptlang.org/)
|
|
13
13
|
[](https://react.dev/)
|
|
14
14
|
[](https://nextjs.org/)
|
|
@@ -272,7 +272,7 @@ Copyright © 2026 **Shawn Rimai**
|
|
|
272
272
|
|
|
273
273
|
</a>
|
|
274
274
|
|
|
275
|
-
<a href="https://www.npmjs.com/package/canopy-ui">
|
|
275
|
+
<a href="https://www.npmjs.com/package/@marv3l/canopy-ui">
|
|
276
276
|
|
|
277
277
|
<img src="https://img.shields.io/badge/npm-Canopy%20UI-CB3837?style=for-the-badge&logo=npm&logoColor=white" />
|
|
278
278
|
|
package/package.json
CHANGED
|
@@ -1,63 +1,89 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
// Import React runtime and component types
|
|
4
3
|
import * as React from "react";
|
|
5
|
-
|
|
6
|
-
import { CheckCircle2, AlertCircle, AlertTriangle, Info, X } from "lucide-react";
|
|
7
|
-
// Import toast hook and type interfaces
|
|
4
|
+
import { CheckCircle2, AlertCircle, AlertTriangle, Info, Loader2, X, LucideIcon } from "lucide-react";
|
|
8
5
|
import { useToast, ToastItem } from "./use-toast";
|
|
9
6
|
|
|
10
|
-
// Props accepted by the root Toaster container mounted in the root layout
|
|
11
7
|
export interface ToasterProps {
|
|
12
|
-
// Global lifespan (in milliseconds) for all toasts; defaults to 4000ms
|
|
13
8
|
defaultDuration?: number;
|
|
14
|
-
// Screen viewport placement position
|
|
15
9
|
position?: "top-right" | "bottom-right" | "top-center" | "bottom-center" | "top-left" | "bottom-left";
|
|
16
10
|
}
|
|
17
11
|
|
|
18
|
-
// Visual preset configurations mapped to your semantic theme tokens
|
|
19
12
|
const variantStyles: Record<
|
|
20
13
|
string,
|
|
21
|
-
{
|
|
14
|
+
{
|
|
15
|
+
bg: string;
|
|
16
|
+
border: string;
|
|
17
|
+
title: string;
|
|
18
|
+
description: string;
|
|
19
|
+
progress: string;
|
|
20
|
+
iconColor: string;
|
|
21
|
+
badge: string;
|
|
22
|
+
icon: LucideIcon | null;
|
|
23
|
+
}
|
|
22
24
|
> = {
|
|
23
25
|
default: {
|
|
24
|
-
bg: "bg-card",
|
|
25
|
-
border: "border-border",
|
|
26
|
-
|
|
27
|
-
|
|
26
|
+
bg: "bg-card dark:bg-neutral-900/95",
|
|
27
|
+
border: "border-border dark:border-neutral-700/80",
|
|
28
|
+
title: "text-neutral-900 dark:text-neutral-50",
|
|
29
|
+
description: "text-neutral-600 dark:text-neutral-200",
|
|
30
|
+
progress: "bg-neutral-900/20 dark:bg-neutral-400",
|
|
31
|
+
iconColor: "text-neutral-700 dark:text-neutral-300",
|
|
32
|
+
badge: "bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-100 border-neutral-300 dark:border-neutral-700",
|
|
28
33
|
icon: null,
|
|
29
34
|
},
|
|
30
35
|
success: {
|
|
31
|
-
bg: "bg-success-bg",
|
|
32
|
-
border: "border-success/40",
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
bg: "bg-success-bg dark:bg-emerald-950/60",
|
|
37
|
+
border: "border-success/40 dark:border-emerald-500/50",
|
|
38
|
+
title: "text-emerald-950 dark:text-emerald-100",
|
|
39
|
+
description: "text-emerald-800 dark:text-emerald-200/90",
|
|
40
|
+
progress: "bg-emerald-600 dark:bg-emerald-400",
|
|
41
|
+
iconColor: "text-emerald-600 dark:text-emerald-400",
|
|
42
|
+
badge: "bg-emerald-100 text-emerald-900 dark:bg-emerald-900/80 dark:text-emerald-100 border-emerald-300 dark:border-emerald-700",
|
|
35
43
|
icon: CheckCircle2,
|
|
36
44
|
},
|
|
37
45
|
error: {
|
|
38
|
-
bg: "bg-destructive/10",
|
|
39
|
-
border: "border-destructive/30",
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
bg: "bg-destructive/10 dark:bg-red-950/60",
|
|
47
|
+
border: "border-destructive/30 dark:border-red-500/50",
|
|
48
|
+
title: "text-red-950 dark:text-red-100",
|
|
49
|
+
description: "text-red-800 dark:text-red-200/90",
|
|
50
|
+
progress: "bg-destructive dark:bg-red-400",
|
|
51
|
+
iconColor: "text-red-600 dark:text-red-400",
|
|
52
|
+
badge: "bg-red-100 text-red-900 dark:bg-red-900/80 dark:text-red-100 border-red-300 dark:border-red-700",
|
|
42
53
|
icon: AlertCircle,
|
|
43
54
|
},
|
|
44
55
|
warning: {
|
|
45
|
-
bg: "bg-warning-bg",
|
|
46
|
-
border: "border-warning/40",
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
bg: "bg-warning-bg dark:bg-amber-950/60",
|
|
57
|
+
border: "border-warning/40 dark:border-amber-500/50",
|
|
58
|
+
title: "text-amber-950 dark:text-amber-100",
|
|
59
|
+
description: "text-amber-800 dark:text-amber-200/90",
|
|
60
|
+
progress: "bg-amber-600 dark:bg-amber-400",
|
|
61
|
+
iconColor: "text-amber-600 dark:text-amber-400",
|
|
62
|
+
badge: "bg-amber-100 text-amber-900 dark:bg-amber-900/80 dark:text-amber-100 border-amber-300 dark:border-amber-700",
|
|
49
63
|
icon: AlertTriangle,
|
|
50
64
|
},
|
|
51
65
|
info: {
|
|
52
|
-
bg: "bg-accent",
|
|
53
|
-
border: "border-primary/30",
|
|
54
|
-
|
|
55
|
-
|
|
66
|
+
bg: "bg-accent dark:bg-sky-950/60",
|
|
67
|
+
border: "border-primary/30 dark:border-sky-500/50",
|
|
68
|
+
title: "text-sky-950 dark:text-sky-100",
|
|
69
|
+
description: "text-sky-800 dark:text-sky-200/90",
|
|
70
|
+
progress: "bg-primary dark:bg-sky-400",
|
|
71
|
+
iconColor: "text-sky-600 dark:text-sky-400",
|
|
72
|
+
badge: "bg-sky-100 text-sky-900 dark:bg-sky-900/80 dark:text-sky-100 border-sky-300 dark:border-sky-700",
|
|
56
73
|
icon: Info,
|
|
57
74
|
},
|
|
75
|
+
loading: {
|
|
76
|
+
bg: "bg-card dark:bg-neutral-900/95",
|
|
77
|
+
border: "border-border dark:border-neutral-700/80",
|
|
78
|
+
title: "text-neutral-900 dark:text-neutral-50",
|
|
79
|
+
description: "text-neutral-600 dark:text-neutral-200",
|
|
80
|
+
progress: "bg-primary dark:bg-sky-400",
|
|
81
|
+
iconColor: "text-primary dark:text-sky-400 animate-spin",
|
|
82
|
+
badge: "bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-100 border-neutral-300 dark:border-neutral-700",
|
|
83
|
+
icon: Loader2,
|
|
84
|
+
},
|
|
58
85
|
};
|
|
59
86
|
|
|
60
|
-
// Root Toaster Component placed into layout.tsx
|
|
61
87
|
export function Toaster({ defaultDuration = 4000, position = "top-center" }: ToasterProps) {
|
|
62
88
|
const { toasts, dismiss } = useToast();
|
|
63
89
|
|
|
@@ -81,6 +107,46 @@ export function Toaster({ defaultDuration = 4000, position = "top-center" }: Toa
|
|
|
81
107
|
transform: scaleX(0);
|
|
82
108
|
}
|
|
83
109
|
}
|
|
110
|
+
@keyframes toast-expand {
|
|
111
|
+
from {
|
|
112
|
+
grid-template-rows: 0fr;
|
|
113
|
+
}
|
|
114
|
+
to {
|
|
115
|
+
grid-template-rows: 1fr;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
@keyframes toast-fade-in {
|
|
119
|
+
from {
|
|
120
|
+
opacity: 0;
|
|
121
|
+
transform: translateY(-4px);
|
|
122
|
+
}
|
|
123
|
+
to {
|
|
124
|
+
opacity: 1;
|
|
125
|
+
transform: translateY(0);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
@keyframes toast-pop {
|
|
129
|
+
0% {
|
|
130
|
+
transform: scale(0.6);
|
|
131
|
+
}
|
|
132
|
+
50% {
|
|
133
|
+
transform: scale(1.2);
|
|
134
|
+
}
|
|
135
|
+
100% {
|
|
136
|
+
transform: scale(1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
@keyframes toast-shake {
|
|
140
|
+
0%, 100% {
|
|
141
|
+
transform: translateX(0);
|
|
142
|
+
}
|
|
143
|
+
25% {
|
|
144
|
+
transform: translateX(-2px);
|
|
145
|
+
}
|
|
146
|
+
75% {
|
|
147
|
+
transform: translateX(2px);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
84
150
|
`}</style>
|
|
85
151
|
|
|
86
152
|
<div className={`fixed z-50 pointer-events-none flex flex-col gap-2 p-4 w-full max-w-sm ${positionClasses}`}>
|
|
@@ -97,7 +163,6 @@ export function Toaster({ defaultDuration = 4000, position = "top-center" }: Toa
|
|
|
97
163
|
);
|
|
98
164
|
}
|
|
99
165
|
|
|
100
|
-
// Atomic Toast Card Component
|
|
101
166
|
function ToastElement({
|
|
102
167
|
toast,
|
|
103
168
|
defaultDuration,
|
|
@@ -107,76 +172,156 @@ function ToastElement({
|
|
|
107
172
|
defaultDuration: number;
|
|
108
173
|
onDismiss: () => void;
|
|
109
174
|
}) {
|
|
110
|
-
const
|
|
175
|
+
const isAutoDismissible = toast.variant !== "loading" && (toast.duration === undefined || toast.duration > 0);
|
|
176
|
+
const activeDuration = toast.duration ?? defaultDuration;
|
|
177
|
+
const showProgress = toast.showProgress ?? isAutoDismissible;
|
|
178
|
+
|
|
179
|
+
const [isPaused, setIsPaused] = React.useState(false);
|
|
180
|
+
const remainingTimeRef = React.useRef<number>(activeDuration);
|
|
181
|
+
const startTimeRef = React.useRef<number>(0);
|
|
182
|
+
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
111
183
|
|
|
184
|
+
// Update remaining ref on prop changes without triggering synchronous setState re-renders
|
|
112
185
|
React.useEffect(() => {
|
|
113
|
-
|
|
114
|
-
|
|
186
|
+
remainingTimeRef.current = activeDuration;
|
|
187
|
+
}, [toast.variant, toast.title, toast.count, activeDuration]);
|
|
188
|
+
|
|
189
|
+
// Handle countdown timeout
|
|
190
|
+
React.useEffect(() => {
|
|
191
|
+
if (!isAutoDismissible || isPaused) {
|
|
192
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
startTimeRef.current = Date.now();
|
|
197
|
+
timerRef.current = setTimeout(() => {
|
|
115
198
|
onDismiss();
|
|
116
|
-
},
|
|
117
|
-
|
|
118
|
-
|
|
199
|
+
}, remainingTimeRef.current);
|
|
200
|
+
|
|
201
|
+
return () => {
|
|
202
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
203
|
+
};
|
|
204
|
+
}, [isAutoDismissible, isPaused, toast.variant, toast.title, toast.count, activeDuration, onDismiss]);
|
|
205
|
+
|
|
206
|
+
const handleMouseEnter = () => {
|
|
207
|
+
if (!isAutoDismissible) return;
|
|
208
|
+
if (timerRef.current) clearTimeout(timerRef.current);
|
|
209
|
+
const elapsed = Date.now() - startTimeRef.current;
|
|
210
|
+
remainingTimeRef.current = Math.max(remainingTimeRef.current - elapsed, 0);
|
|
211
|
+
setIsPaused(true);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const handleMouseLeave = () => {
|
|
215
|
+
if (!isAutoDismissible) return;
|
|
216
|
+
setIsPaused(false);
|
|
217
|
+
};
|
|
119
218
|
|
|
120
219
|
const variant = toast.variant || "default";
|
|
121
220
|
const defaultStyle = variantStyles[variant] || variantStyles.default;
|
|
122
|
-
const IconComponent = defaultStyle.icon;
|
|
221
|
+
const IconComponent = toast.icon !== undefined ? null : defaultStyle.icon;
|
|
123
222
|
|
|
124
|
-
// 1. Only build inline styles for values that are explicitly provided
|
|
125
223
|
const customInlineStyle: React.CSSProperties = {};
|
|
126
224
|
if (toast.customColor?.bg) customInlineStyle.backgroundColor = toast.customColor.bg;
|
|
127
225
|
if (toast.customColor?.border) customInlineStyle.borderColor = toast.customColor.border;
|
|
128
226
|
if (toast.customColor?.text) customInlineStyle.color = toast.customColor.text;
|
|
129
227
|
|
|
130
|
-
// 2. Prevent default Tailwind classes from overriding user custom classes or inline styles
|
|
131
228
|
const userHasBg = Boolean(toast.customColor?.bg || toast.className?.match(/(?:^|\s)bg-/));
|
|
132
229
|
const userHasBorder = Boolean(toast.customColor?.border || toast.className?.match(/(?:^|\s)border-/));
|
|
133
|
-
|
|
230
|
+
|
|
231
|
+
const isError = variant === "error";
|
|
134
232
|
|
|
135
233
|
return (
|
|
136
234
|
<div
|
|
235
|
+
role={isError ? "alert" : "status"}
|
|
236
|
+
aria-live={isError ? "assertive" : "polite"}
|
|
137
237
|
style={customInlineStyle}
|
|
138
|
-
|
|
238
|
+
onMouseEnter={handleMouseEnter}
|
|
239
|
+
onMouseLeave={handleMouseLeave}
|
|
240
|
+
className={`pointer-events-auto relative overflow-hidden flex items-start gap-3 w-full p-4 rounded-[var(--radius-lg,0.625rem)] border shadow-xl dark:shadow-2xl dark:shadow-black/70 dark:ring-1 dark:ring-white/10 backdrop-blur-md transition-all duration-300 ease-out ${
|
|
139
241
|
!userHasBg ? defaultStyle.bg : ""
|
|
140
|
-
} ${!userHasBorder ? defaultStyle.border : ""} ${
|
|
141
|
-
!userHasText ? defaultStyle.text : ""
|
|
142
|
-
} ${toast.className || ""}`}
|
|
242
|
+
} ${!userHasBorder ? defaultStyle.border : ""} ${toast.className || ""}`}
|
|
143
243
|
>
|
|
144
|
-
{
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
244
|
+
{toast.icon !== undefined ? (
|
|
245
|
+
<div className="shrink-0 mt-0.5">{toast.icon}</div>
|
|
246
|
+
) : (
|
|
247
|
+
IconComponent && (
|
|
248
|
+
<IconComponent
|
|
249
|
+
className={`w-5 h-5 mt-0.5 shrink-0 ${defaultStyle.iconColor}`}
|
|
250
|
+
style={{ color: toast.customColor?.icon }}
|
|
251
|
+
/>
|
|
252
|
+
)
|
|
150
253
|
)}
|
|
151
254
|
|
|
152
255
|
{/* Toast Content Area */}
|
|
153
|
-
<div className="flex-1 text-sm
|
|
154
|
-
|
|
256
|
+
<div className="flex-1 text-sm">
|
|
257
|
+
<div className="flex items-center gap-2">
|
|
258
|
+
{toast.title && (
|
|
259
|
+
<div className={`font-semibold leading-tight tracking-tight ${defaultStyle.title}`}>
|
|
260
|
+
{toast.title}
|
|
261
|
+
</div>
|
|
262
|
+
)}
|
|
263
|
+
|
|
264
|
+
{/* Duplication Counter / Max Badge */}
|
|
265
|
+
{toast.count > 1 && (
|
|
266
|
+
<span
|
|
267
|
+
key={`${toast.count}-${toast.maxReached}`}
|
|
268
|
+
style={{
|
|
269
|
+
animation: toast.maxReached
|
|
270
|
+
? "toast-shake 200ms ease-in-out"
|
|
271
|
+
: "toast-pop 200ms ease-out",
|
|
272
|
+
}}
|
|
273
|
+
className={`inline-flex items-center justify-center px-1.5 py-0.5 text-[10px] font-bold rounded-full border shadow-sm transition-transform ${
|
|
274
|
+
defaultStyle.badge
|
|
275
|
+
} ${toast.maxReached ? "ring-1 ring-destructive/40" : ""}`}
|
|
276
|
+
>
|
|
277
|
+
{toast.maxReached ? `×${toast.count} (max)` : `×${toast.count}`}
|
|
278
|
+
</span>
|
|
279
|
+
)}
|
|
280
|
+
</div>
|
|
281
|
+
|
|
155
282
|
{toast.description && (
|
|
156
|
-
<div
|
|
157
|
-
|
|
283
|
+
<div
|
|
284
|
+
className="grid overflow-hidden"
|
|
285
|
+
style={{
|
|
286
|
+
gridTemplateRows: "0fr",
|
|
287
|
+
animation: "toast-expand 250ms cubic-bezier(0.16, 1, 0.3, 1) 250ms forwards",
|
|
288
|
+
}}
|
|
289
|
+
>
|
|
290
|
+
<div className="overflow-hidden">
|
|
291
|
+
<div
|
|
292
|
+
className={`leading-relaxed text-xs pt-1 opacity-0 font-normal ${defaultStyle.description}`}
|
|
293
|
+
style={{
|
|
294
|
+
animation: "toast-fade-in 200ms ease-out 250ms forwards",
|
|
295
|
+
}}
|
|
296
|
+
>
|
|
297
|
+
{toast.description}
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
158
300
|
</div>
|
|
159
301
|
)}
|
|
160
|
-
|
|
302
|
+
|
|
303
|
+
{toast.action && <div className="pt-2">{toast.action}</div>}
|
|
161
304
|
</div>
|
|
162
305
|
|
|
163
|
-
{/* Close button */}
|
|
164
306
|
<button
|
|
165
307
|
onClick={onDismiss}
|
|
166
|
-
|
|
308
|
+
aria-label="Close toast"
|
|
309
|
+
className="p-1 rounded-md opacity-70 hover:opacity-100 hover:bg-neutral-500/15 dark:hover:bg-white/10 transition-colors text-neutral-500 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white"
|
|
167
310
|
>
|
|
168
311
|
<X className="w-4 h-4" />
|
|
169
312
|
</button>
|
|
170
313
|
|
|
171
|
-
{/*
|
|
172
|
-
{
|
|
314
|
+
{/* Progress Bar resets animation on each state change */}
|
|
315
|
+
{showProgress && isAutoDismissible && (
|
|
173
316
|
<div
|
|
317
|
+
key={`${toast.id}-${toast.variant}-${toast.count}`}
|
|
174
318
|
className={`absolute bottom-0 left-0 right-0 h-1 origin-left ${
|
|
175
319
|
!toast.customColor?.progress ? defaultStyle.progress : ""
|
|
176
320
|
}`}
|
|
177
321
|
style={{
|
|
178
322
|
backgroundColor: toast.customColor?.progress,
|
|
179
|
-
animation: `toast-progress ${
|
|
323
|
+
animation: `toast-progress ${activeDuration}ms linear forwards`,
|
|
324
|
+
animationPlayState: isPaused ? "paused" : "running",
|
|
180
325
|
}}
|
|
181
326
|
/>
|
|
182
327
|
)}
|
|
@@ -1,214 +1,343 @@
|
|
|
1
|
-
"use client"
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
|
-
// Import React to access state hooks and ReactNode type definitions
|
|
4
3
|
import * as React from "react";
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Supported visual styles and operational modes for toasts.
|
|
7
|
+
* - "default" | "success" | "error" | "warning" | "info": Semantic presets
|
|
8
|
+
* - "custom": Custom token/color overrides
|
|
9
|
+
* - "loading": Persistent spinner mode for asynchronous operations
|
|
10
|
+
*/
|
|
11
|
+
export type ToastVariant =
|
|
12
|
+
| "default"
|
|
13
|
+
| "success"
|
|
14
|
+
| "error"
|
|
15
|
+
| "warning"
|
|
16
|
+
| "info"
|
|
17
|
+
| "custom"
|
|
18
|
+
| "loading";
|
|
8
19
|
|
|
9
|
-
// Interface defining all configurable parameters when triggering a toast notification
|
|
10
20
|
export interface ToastOptions {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
21
|
+
/** Optional custom identifier. If omitted, a collision-resistant UUID is generated. */
|
|
22
|
+
id?: string;
|
|
23
|
+
/** Primary title text or JSX element displayed in the toast header. */
|
|
24
|
+
title?: React.ReactNode;
|
|
25
|
+
/** Secondary explanatory text or JSX node rendered below the title. */
|
|
26
|
+
description?: React.ReactNode;
|
|
27
|
+
/** Interactive action button or control rendered at the bottom of the toast. */
|
|
28
|
+
action?: React.ReactNode;
|
|
29
|
+
/** Visual variant styling preset. Defaults to "default". */
|
|
30
|
+
variant?: ToastVariant;
|
|
31
|
+
/** Display duration in milliseconds before auto-dismissing. Set to 0 or Infinity to prevent auto-dismiss. */
|
|
32
|
+
duration?: number;
|
|
33
|
+
/** Maximum number of duplicate message stacks allowed for this specific toast (defaults to 5). */
|
|
34
|
+
maxCount?: number;
|
|
35
|
+
/** Explicitly toggle the animated bottom progress bar. Defaults to true when auto-dismissible. */
|
|
36
|
+
showProgress?: boolean;
|
|
37
|
+
/** Optional custom icon node to override the variant preset icon. */
|
|
38
|
+
icon?: React.ReactNode;
|
|
39
|
+
/** Custom inline token styling overrides for background, border, text, progress bar, and icon. */
|
|
40
|
+
customColor?: {
|
|
41
|
+
bg?: string;
|
|
42
|
+
text?: string;
|
|
43
|
+
border?: string;
|
|
44
|
+
progress?: string;
|
|
45
|
+
icon?: string;
|
|
46
|
+
};
|
|
47
|
+
/** Additional Tailwind or CSS class names to apply to the root toast card container. */
|
|
48
|
+
className?: string;
|
|
38
49
|
}
|
|
39
50
|
|
|
40
|
-
// Internal representation of an active toast item containing open state
|
|
41
51
|
export interface ToastItem extends ToastOptions {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
id: string;
|
|
53
|
+
open: boolean;
|
|
54
|
+
count: number;
|
|
55
|
+
maxReached?: boolean;
|
|
46
56
|
}
|
|
47
57
|
|
|
48
|
-
//
|
|
58
|
+
// -----------------------------------------------------------------------------
|
|
59
|
+
// Constants & Configuration
|
|
60
|
+
// -----------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/** Maximum number of simultaneous toasts rendered on screen at any time. */
|
|
49
63
|
const TOAST_LIMIT = 5;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
|
|
65
|
+
/** Default duplicate stack cap if not overridden per toast. */
|
|
66
|
+
const DEFAULT_MAX_COUNT = 5;
|
|
67
|
+
|
|
68
|
+
/** Delay in ms before unmounting a dismissed toast from the state tree. */
|
|
69
|
+
const TOAST_REMOVE_DELAY = 0;
|
|
70
|
+
|
|
71
|
+
// -----------------------------------------------------------------------------
|
|
72
|
+
// Action Types & State Management
|
|
73
|
+
// -----------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
type Action =
|
|
76
|
+
| { type: "ADD_TOAST"; toast: ToastItem }
|
|
77
|
+
| { type: "UPDATE_TOAST"; toast: Partial<ToastItem> }
|
|
78
|
+
| { type: "DISMISS_TOAST"; toastId?: string }
|
|
79
|
+
| { type: "REMOVE_TOAST"; toastId?: string };
|
|
80
|
+
|
|
65
81
|
interface State {
|
|
66
|
-
|
|
82
|
+
toasts: ToastItem[];
|
|
67
83
|
}
|
|
68
84
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Generates a collision-resistant unique identifier.
|
|
87
|
+
* Uses native crypto.randomUUID when available, falling back to high-resolution timestamps.
|
|
88
|
+
*/
|
|
73
89
|
function genId(): string {
|
|
74
|
-
|
|
75
|
-
return
|
|
90
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
91
|
+
return crypto.randomUUID();
|
|
92
|
+
}
|
|
93
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
76
94
|
}
|
|
77
95
|
|
|
78
|
-
|
|
96
|
+
/** Tracks active unmount timers to prevent duplicate queue dispatches. */
|
|
79
97
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
80
98
|
|
|
81
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Schedules a toast for removal from memory after dismissal animations complete.
|
|
101
|
+
*/
|
|
82
102
|
const addToRemoveQueue = (toastId: string) => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
dispatch({ type: "REMOVE_TOAST", toastId });
|
|
92
|
-
}, TOAST_REMOVE_DELAY);
|
|
93
|
-
|
|
94
|
-
// Store reference in tracking map
|
|
95
|
-
toastTimeouts.set(toastId, timeout);
|
|
103
|
+
if (toastTimeouts.has(toastId)) return;
|
|
104
|
+
|
|
105
|
+
const timeout = setTimeout(() => {
|
|
106
|
+
toastTimeouts.delete(toastId);
|
|
107
|
+
dispatch({ type: "REMOVE_TOAST", toastId });
|
|
108
|
+
}, TOAST_REMOVE_DELAY);
|
|
109
|
+
|
|
110
|
+
toastTimeouts.set(toastId, timeout);
|
|
96
111
|
};
|
|
97
112
|
|
|
98
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Main state reducer managing insertion, deduplication stacking, updates, and dismissals.
|
|
115
|
+
*/
|
|
99
116
|
export const reducer = (state: State, action: Action): State => {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
117
|
+
switch (action.type) {
|
|
118
|
+
case "ADD_TOAST": {
|
|
119
|
+
// 1. In-place update: If a toast with this exact ID already exists (e.g. toast.promise transitions), update it
|
|
120
|
+
const existingByIdIndex = state.toasts.findIndex((t) => t.id === action.toast.id);
|
|
121
|
+
|
|
122
|
+
if (existingByIdIndex !== -1) {
|
|
123
|
+
return {
|
|
124
|
+
...state,
|
|
125
|
+
toasts: state.toasts.map((t) =>
|
|
126
|
+
t.id === action.toast.id ? { ...t, ...action.toast, open: true } : t
|
|
127
|
+
),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// 2. Duplicate Detection: Match on open state, title, description, and variant to increment badge counter
|
|
132
|
+
const existingIndex = state.toasts.findIndex(
|
|
133
|
+
(t) =>
|
|
134
|
+
t.open &&
|
|
135
|
+
t.title === action.toast.title &&
|
|
136
|
+
t.description === action.toast.description &&
|
|
137
|
+
(t.variant || "default") === (action.toast.variant || "default")
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (existingIndex !== -1) {
|
|
141
|
+
const existing = state.toasts[existingIndex];
|
|
142
|
+
const maxLimit = existing.maxCount ?? action.toast.maxCount ?? DEFAULT_MAX_COUNT;
|
|
143
|
+
|
|
144
|
+
// Cap the count at maxLimit
|
|
145
|
+
const newCount = Math.min(existing.count + 1, maxLimit);
|
|
146
|
+
const maxReached = existing.count + 1 >= maxLimit;
|
|
147
|
+
|
|
148
|
+
const updatedToast: ToastItem = {
|
|
149
|
+
...existing,
|
|
150
|
+
...action.toast,
|
|
151
|
+
id: existing.id,
|
|
152
|
+
count: newCount,
|
|
153
|
+
maxReached,
|
|
154
|
+
open: true,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
// Move updated toast to the top of the stack
|
|
158
|
+
const rest = state.toasts.filter((t) => t.id !== existing.id);
|
|
159
|
+
return {
|
|
160
|
+
...state,
|
|
161
|
+
toasts: [updatedToast, ...rest].slice(0, TOAST_LIMIT),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 3. New Toast: Prepend to list and truncate at TOAST_LIMIT
|
|
166
|
+
return {
|
|
167
|
+
...state,
|
|
168
|
+
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case "UPDATE_TOAST":
|
|
173
|
+
return {
|
|
174
|
+
...state,
|
|
175
|
+
toasts: state.toasts.map((t) =>
|
|
176
|
+
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
|
177
|
+
),
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
case "DISMISS_TOAST": {
|
|
181
|
+
const { toastId } = action;
|
|
182
|
+
|
|
183
|
+
// Queue removal for specific toast or all active toasts
|
|
184
|
+
if (toastId) {
|
|
185
|
+
addToRemoveQueue(toastId);
|
|
186
|
+
} else {
|
|
187
|
+
state.toasts.forEach((toast) => addToRemoveQueue(toast.id));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Mark toast as closed to trigger exit transitions
|
|
191
|
+
return {
|
|
192
|
+
...state,
|
|
193
|
+
toasts: state.toasts.map((t) =>
|
|
194
|
+
t.id === toastId || toastId === undefined ? { ...t, open: false } : t
|
|
195
|
+
),
|
|
196
|
+
};
|
|
149
197
|
}
|
|
198
|
+
|
|
199
|
+
case "REMOVE_TOAST":
|
|
200
|
+
if (action.toastId === undefined) return { ...state, toasts: [] };
|
|
201
|
+
return {
|
|
202
|
+
...state,
|
|
203
|
+
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
default:
|
|
207
|
+
return state;
|
|
208
|
+
}
|
|
150
209
|
};
|
|
151
210
|
|
|
152
|
-
//
|
|
153
|
-
|
|
211
|
+
// -----------------------------------------------------------------------------
|
|
212
|
+
// Store Listeners & Dispatch Dispatcher
|
|
213
|
+
// -----------------------------------------------------------------------------
|
|
154
214
|
|
|
155
|
-
|
|
215
|
+
const listeners: Array<(state: State) => void> = [];
|
|
156
216
|
let memoryState: State = { toasts: [] };
|
|
157
217
|
|
|
158
|
-
// Dispatches actions to state and notifies all registered React hook subscribers
|
|
159
218
|
function dispatch(action: Action) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
// Notify every mounted React component listener
|
|
163
|
-
listeners.forEach((listener) => listener(memoryState));
|
|
219
|
+
memoryState = reducer(memoryState, action);
|
|
220
|
+
listeners.forEach((listener) => listener(memoryState));
|
|
164
221
|
}
|
|
165
222
|
|
|
166
|
-
//
|
|
223
|
+
// -----------------------------------------------------------------------------
|
|
224
|
+
// Core Toast Dispatcher & Convenience Helpers
|
|
225
|
+
// -----------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Base dispatcher function to spawn or update a toast alert.
|
|
229
|
+
*/
|
|
167
230
|
export function toast(props: ToastOptions) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
},
|
|
186
|
-
});
|
|
231
|
+
const id = props.id || genId();
|
|
232
|
+
|
|
233
|
+
const update = (updatedProps: ToastOptions) =>
|
|
234
|
+
dispatch({ type: "UPDATE_TOAST", toast: { ...updatedProps, id } });
|
|
235
|
+
|
|
236
|
+
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
|
237
|
+
|
|
238
|
+
dispatch({
|
|
239
|
+
type: "ADD_TOAST",
|
|
240
|
+
toast: {
|
|
241
|
+
...props,
|
|
242
|
+
id,
|
|
243
|
+
open: true,
|
|
244
|
+
count: 1,
|
|
245
|
+
maxReached: false,
|
|
246
|
+
},
|
|
247
|
+
});
|
|
187
248
|
|
|
188
|
-
|
|
189
|
-
return { id, dismiss, update };
|
|
249
|
+
return { id, dismiss, update };
|
|
190
250
|
}
|
|
191
251
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
252
|
+
/** Convenience helper: Spawns a success variant toast. */
|
|
253
|
+
toast.success = (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "variant">) =>
|
|
254
|
+
toast({ ...options, title, variant: "success" });
|
|
255
|
+
|
|
256
|
+
/** Convenience helper: Spawns an error variant toast. */
|
|
257
|
+
toast.error = (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "variant">) =>
|
|
258
|
+
toast({ ...options, title, variant: "error" });
|
|
259
|
+
|
|
260
|
+
/** Convenience helper: Spawns a warning variant toast. */
|
|
261
|
+
toast.warning = (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "variant">) =>
|
|
262
|
+
toast({ ...options, title, variant: "warning" });
|
|
263
|
+
|
|
264
|
+
/** Convenience helper: Spawns an informational variant toast. */
|
|
265
|
+
toast.info = (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "variant">) =>
|
|
266
|
+
toast({ ...options, title, variant: "info" });
|
|
267
|
+
|
|
268
|
+
/** Convenience helper: Spawns a persistent loading variant toast with infinite duration. */
|
|
269
|
+
toast.loading = (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "variant">) =>
|
|
270
|
+
toast({ ...options, title, variant: "loading", duration: 0 });
|
|
271
|
+
|
|
272
|
+
/** Global method to dismiss a specific toast by ID or all active toasts if no ID is passed. */
|
|
273
|
+
toast.dismiss = (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId });
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Asynchronous promise lifecycle handler.
|
|
277
|
+
* Displays a loading state and seamlessly transitions to success or error on resolution.
|
|
278
|
+
*/
|
|
279
|
+
toast.promise = <T,>(
|
|
280
|
+
promise: Promise<T> | (() => Promise<T>),
|
|
281
|
+
msgs: {
|
|
282
|
+
loading: React.ReactNode;
|
|
283
|
+
success: React.ReactNode | ((data: T) => React.ReactNode);
|
|
284
|
+
error: React.ReactNode | ((err: unknown) => React.ReactNode);
|
|
285
|
+
},
|
|
286
|
+
options?: ToastOptions
|
|
287
|
+
) => {
|
|
288
|
+
const instance = toast({
|
|
289
|
+
...options,
|
|
290
|
+
variant: "loading",
|
|
291
|
+
title: msgs.loading,
|
|
292
|
+
duration: 0,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const promiseFn = typeof promise === "function" ? promise() : promise;
|
|
296
|
+
|
|
297
|
+
promiseFn
|
|
298
|
+
.then((data) => {
|
|
299
|
+
const successTitle = typeof msgs.success === "function" ? msgs.success(data) : msgs.success;
|
|
300
|
+
toast.success(successTitle, {
|
|
301
|
+
...options,
|
|
302
|
+
id: instance.id,
|
|
303
|
+
duration: options?.duration ?? 4000,
|
|
304
|
+
});
|
|
305
|
+
})
|
|
306
|
+
.catch((err: unknown) => {
|
|
307
|
+
const errorTitle = typeof msgs.error === "function" ? msgs.error(err) : msgs.error;
|
|
308
|
+
toast.error(errorTitle, {
|
|
309
|
+
...options,
|
|
310
|
+
id: instance.id,
|
|
311
|
+
duration: options?.duration ?? 5000,
|
|
312
|
+
});
|
|
313
|
+
});
|
|
207
314
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
315
|
+
return promiseFn;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// -----------------------------------------------------------------------------
|
|
319
|
+
// React Consumer Hook
|
|
320
|
+
// -----------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* React hook providing reactive toast state, dispatchers, and dismissal handlers.
|
|
324
|
+
*/
|
|
325
|
+
export function useToast() {
|
|
326
|
+
const [state, setState] = React.useState<State>(memoryState);
|
|
327
|
+
|
|
328
|
+
React.useEffect(() => {
|
|
329
|
+
listeners.push(setState);
|
|
330
|
+
return () => {
|
|
331
|
+
const index = listeners.indexOf(setState);
|
|
332
|
+
if (index > -1) {
|
|
333
|
+
listeners.splice(index, 1);
|
|
334
|
+
}
|
|
213
335
|
};
|
|
336
|
+
}, []);
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
...state,
|
|
340
|
+
toast,
|
|
341
|
+
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
|
342
|
+
};
|
|
214
343
|
}
|