@marv3l/canopy-ui 1.0.4 → 1.1.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/package.json +1 -1
- package/templates/toast/toast.tsx +142 -37
- package/templates/toast/use-toast.ts +197 -152
package/package.json
CHANGED
|
@@ -15,44 +15,68 @@ export interface ToasterProps {
|
|
|
15
15
|
position?: "top-right" | "bottom-right" | "top-center" | "bottom-center" | "top-left" | "bottom-left";
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
-
// Visual preset configurations mapped to
|
|
18
|
+
// Visual preset configurations mapped to semantic & dark-safe theme tokens
|
|
19
19
|
const variantStyles: Record<
|
|
20
20
|
string,
|
|
21
|
-
{
|
|
21
|
+
{
|
|
22
|
+
bg: string;
|
|
23
|
+
border: string;
|
|
24
|
+
title: string;
|
|
25
|
+
description: string;
|
|
26
|
+
progress: string;
|
|
27
|
+
iconColor: string;
|
|
28
|
+
badge: string;
|
|
29
|
+
icon: any;
|
|
30
|
+
}
|
|
22
31
|
> = {
|
|
23
32
|
default: {
|
|
24
|
-
bg: "bg-card",
|
|
25
|
-
border: "border-border",
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
bg: "bg-card dark:bg-neutral-900/95",
|
|
34
|
+
border: "border-border dark:border-neutral-700/80",
|
|
35
|
+
title: "text-neutral-900 dark:text-neutral-50",
|
|
36
|
+
description: "text-neutral-600 dark:text-neutral-200",
|
|
37
|
+
progress: "bg-neutral-900/20 dark:bg-neutral-400",
|
|
38
|
+
iconColor: "text-neutral-700 dark:text-neutral-300",
|
|
39
|
+
badge: "bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-100 border-neutral-300 dark:border-neutral-700",
|
|
28
40
|
icon: null,
|
|
29
41
|
},
|
|
30
42
|
success: {
|
|
31
|
-
bg: "bg-success-bg",
|
|
32
|
-
border: "border-success/40",
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
bg: "bg-success-bg dark:bg-emerald-950/60",
|
|
44
|
+
border: "border-success/40 dark:border-emerald-500/50",
|
|
45
|
+
title: "text-emerald-950 dark:text-emerald-100",
|
|
46
|
+
description: "text-emerald-800 dark:text-emerald-200/90",
|
|
47
|
+
progress: "bg-emerald-600 dark:bg-emerald-400",
|
|
48
|
+
iconColor: "text-emerald-600 dark:text-emerald-400",
|
|
49
|
+
badge: "bg-emerald-100 text-emerald-900 dark:bg-emerald-900/80 dark:text-emerald-100 border-emerald-300 dark:border-emerald-700",
|
|
35
50
|
icon: CheckCircle2,
|
|
36
51
|
},
|
|
37
52
|
error: {
|
|
38
|
-
bg: "bg-destructive/10",
|
|
39
|
-
border: "border-destructive/30",
|
|
40
|
-
|
|
41
|
-
|
|
53
|
+
bg: "bg-destructive/10 dark:bg-red-950/60",
|
|
54
|
+
border: "border-destructive/30 dark:border-red-500/50",
|
|
55
|
+
title: "text-red-950 dark:text-red-100",
|
|
56
|
+
description: "text-red-800 dark:text-red-200/90",
|
|
57
|
+
progress: "bg-destructive dark:bg-red-400",
|
|
58
|
+
iconColor: "text-red-600 dark:text-red-400",
|
|
59
|
+
badge: "bg-red-100 text-red-900 dark:bg-red-900/80 dark:text-red-100 border-red-300 dark:border-red-700",
|
|
42
60
|
icon: AlertCircle,
|
|
43
61
|
},
|
|
44
62
|
warning: {
|
|
45
|
-
bg: "bg-warning-bg",
|
|
46
|
-
border: "border-warning/40",
|
|
47
|
-
|
|
48
|
-
|
|
63
|
+
bg: "bg-warning-bg dark:bg-amber-950/60",
|
|
64
|
+
border: "border-warning/40 dark:border-amber-500/50",
|
|
65
|
+
title: "text-amber-950 dark:text-amber-100",
|
|
66
|
+
description: "text-amber-800 dark:text-amber-200/90",
|
|
67
|
+
progress: "bg-amber-600 dark:bg-amber-400",
|
|
68
|
+
iconColor: "text-amber-600 dark:text-amber-400",
|
|
69
|
+
badge: "bg-amber-100 text-amber-900 dark:bg-amber-900/80 dark:text-amber-100 border-amber-300 dark:border-amber-700",
|
|
49
70
|
icon: AlertTriangle,
|
|
50
71
|
},
|
|
51
72
|
info: {
|
|
52
|
-
bg: "bg-accent",
|
|
53
|
-
border: "border-primary/30",
|
|
54
|
-
|
|
55
|
-
|
|
73
|
+
bg: "bg-accent dark:bg-sky-950/60",
|
|
74
|
+
border: "border-primary/30 dark:border-sky-500/50",
|
|
75
|
+
title: "text-sky-950 dark:text-sky-100",
|
|
76
|
+
description: "text-sky-800 dark:text-sky-200/90",
|
|
77
|
+
progress: "bg-primary dark:bg-sky-400",
|
|
78
|
+
iconColor: "text-sky-600 dark:text-sky-400",
|
|
79
|
+
badge: "bg-sky-100 text-sky-900 dark:bg-sky-900/80 dark:text-sky-100 border-sky-300 dark:border-sky-700",
|
|
56
80
|
icon: Info,
|
|
57
81
|
},
|
|
58
82
|
};
|
|
@@ -81,6 +105,46 @@ export function Toaster({ defaultDuration = 4000, position = "top-center" }: Toa
|
|
|
81
105
|
transform: scaleX(0);
|
|
82
106
|
}
|
|
83
107
|
}
|
|
108
|
+
@keyframes toast-expand {
|
|
109
|
+
from {
|
|
110
|
+
grid-template-rows: 0fr;
|
|
111
|
+
}
|
|
112
|
+
to {
|
|
113
|
+
grid-template-rows: 1fr;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
@keyframes toast-fade-in {
|
|
117
|
+
from {
|
|
118
|
+
opacity: 0;
|
|
119
|
+
transform: translateY(-4px);
|
|
120
|
+
}
|
|
121
|
+
to {
|
|
122
|
+
opacity: 1;
|
|
123
|
+
transform: translateY(0);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
@keyframes toast-pop {
|
|
127
|
+
0% {
|
|
128
|
+
transform: scale(0.6);
|
|
129
|
+
}
|
|
130
|
+
50% {
|
|
131
|
+
transform: scale(1.2);
|
|
132
|
+
}
|
|
133
|
+
100% {
|
|
134
|
+
transform: scale(1);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
@keyframes toast-shake {
|
|
138
|
+
0%, 100% {
|
|
139
|
+
transform: translateX(0);
|
|
140
|
+
}
|
|
141
|
+
25% {
|
|
142
|
+
transform: translateX(-2px);
|
|
143
|
+
}
|
|
144
|
+
75% {
|
|
145
|
+
transform: translateX(2px);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
84
148
|
`}</style>
|
|
85
149
|
|
|
86
150
|
<div className={`fixed z-50 pointer-events-none flex flex-col gap-2 p-4 w-full max-w-sm ${positionClasses}`}>
|
|
@@ -109,13 +173,14 @@ function ToastElement({
|
|
|
109
173
|
}) {
|
|
110
174
|
const duration = toast.duration ?? defaultDuration;
|
|
111
175
|
|
|
176
|
+
// Reset lifespan timer whenever count or duration changes
|
|
112
177
|
React.useEffect(() => {
|
|
113
178
|
if (duration <= 0) return;
|
|
114
179
|
const timer = setTimeout(() => {
|
|
115
180
|
onDismiss();
|
|
116
181
|
}, duration);
|
|
117
182
|
return () => clearTimeout(timer);
|
|
118
|
-
}, [duration, onDismiss]);
|
|
183
|
+
}, [duration, toast.count, onDismiss]);
|
|
119
184
|
|
|
120
185
|
const variant = toast.variant || "default";
|
|
121
186
|
const defaultStyle = variantStyles[variant] || variantStyles.default;
|
|
@@ -130,47 +195,87 @@ function ToastElement({
|
|
|
130
195
|
// 2. Prevent default Tailwind classes from overriding user custom classes or inline styles
|
|
131
196
|
const userHasBg = Boolean(toast.customColor?.bg || toast.className?.match(/(?:^|\s)bg-/));
|
|
132
197
|
const userHasBorder = Boolean(toast.customColor?.border || toast.className?.match(/(?:^|\s)border-/));
|
|
133
|
-
const userHasText = Boolean(toast.customColor?.text || toast.className?.match(/(?:^|\s)text-/));
|
|
134
198
|
|
|
135
199
|
return (
|
|
136
200
|
<div
|
|
137
201
|
style={customInlineStyle}
|
|
138
|
-
className={`pointer-events-auto relative overflow-hidden flex items-start gap-3 w-full p-4 rounded-[var(--radius-lg,0.625rem)] border shadow-
|
|
202
|
+
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
203
|
!userHasBg ? defaultStyle.bg : ""
|
|
140
|
-
} ${!userHasBorder ? defaultStyle.border : ""} ${
|
|
141
|
-
!userHasText ? defaultStyle.text : ""
|
|
142
|
-
} ${toast.className || ""}`}
|
|
204
|
+
} ${!userHasBorder ? defaultStyle.border : ""} ${toast.className || ""}`}
|
|
143
205
|
>
|
|
144
206
|
{/* Render icon if preset defines one */}
|
|
145
207
|
{IconComponent && (
|
|
146
208
|
<IconComponent
|
|
147
|
-
className=
|
|
209
|
+
className={`w-5 h-5 mt-0.5 shrink-0 ${defaultStyle.iconColor}`}
|
|
148
210
|
style={{ color: toast.customColor?.icon }}
|
|
149
211
|
/>
|
|
150
212
|
)}
|
|
151
213
|
|
|
152
214
|
{/* Toast Content Area */}
|
|
153
|
-
<div className="flex-1 text-sm
|
|
154
|
-
|
|
215
|
+
<div className="flex-1 text-sm">
|
|
216
|
+
<div className="flex items-center gap-2">
|
|
217
|
+
{toast.title && (
|
|
218
|
+
<div className={`font-semibold leading-tight tracking-tight ${defaultStyle.title}`}>
|
|
219
|
+
{toast.title}
|
|
220
|
+
</div>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
{/* Duplication Counter / Max Badge */}
|
|
224
|
+
{toast.count > 1 && (
|
|
225
|
+
<span
|
|
226
|
+
key={`${toast.count}-${toast.maxReached}`}
|
|
227
|
+
style={{
|
|
228
|
+
animation: toast.maxReached
|
|
229
|
+
? "toast-shake 200ms ease-in-out"
|
|
230
|
+
: "toast-pop 200ms ease-out",
|
|
231
|
+
}}
|
|
232
|
+
className={`inline-flex items-center justify-center px-1.5 py-0.5 text-[10px] font-bold rounded-full border shadow-sm transition-transform ${
|
|
233
|
+
defaultStyle.badge
|
|
234
|
+
} ${toast.maxReached ? "ring-1 ring-destructive/40" : ""}`}
|
|
235
|
+
>
|
|
236
|
+
{toast.maxReached ? `×${toast.count} (max)` : `×${toast.count}`}
|
|
237
|
+
</span>
|
|
238
|
+
)}
|
|
239
|
+
</div>
|
|
240
|
+
|
|
241
|
+
{/* Collapsible/Expanding Description Wrapper */}
|
|
155
242
|
{toast.description && (
|
|
156
|
-
<div
|
|
157
|
-
|
|
243
|
+
<div
|
|
244
|
+
className="grid overflow-hidden"
|
|
245
|
+
style={{
|
|
246
|
+
gridTemplateRows: "0fr",
|
|
247
|
+
animation: "toast-expand 250ms cubic-bezier(0.16, 1, 0.3, 1) 250ms forwards",
|
|
248
|
+
}}
|
|
249
|
+
>
|
|
250
|
+
<div className="overflow-hidden">
|
|
251
|
+
<div
|
|
252
|
+
className={`leading-relaxed text-xs pt-1 opacity-0 font-normal ${defaultStyle.description}`}
|
|
253
|
+
style={{
|
|
254
|
+
animation: "toast-fade-in 200ms ease-out 250ms forwards",
|
|
255
|
+
}}
|
|
256
|
+
>
|
|
257
|
+
{toast.description}
|
|
258
|
+
</div>
|
|
259
|
+
</div>
|
|
158
260
|
</div>
|
|
159
261
|
)}
|
|
160
|
-
|
|
262
|
+
|
|
263
|
+
{/* Interactive Action Button Slot */}
|
|
264
|
+
{toast.action && <div className="pt-2">{toast.action}</div>}
|
|
161
265
|
</div>
|
|
162
266
|
|
|
163
|
-
{/* Close
|
|
267
|
+
{/* Close Button */}
|
|
164
268
|
<button
|
|
165
269
|
onClick={onDismiss}
|
|
166
|
-
className="p-1 rounded-md opacity-70 hover:opacity-100 hover:bg-
|
|
270
|
+
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
271
|
>
|
|
168
272
|
<X className="w-4 h-4" />
|
|
169
273
|
</button>
|
|
170
274
|
|
|
171
|
-
{/* Animated Lifespan Progress Bar */}
|
|
275
|
+
{/* Animated Lifespan Progress Bar (Resets on stack increment) */}
|
|
172
276
|
{duration > 0 && (
|
|
173
277
|
<div
|
|
278
|
+
key={toast.count}
|
|
174
279
|
className={`absolute bottom-0 left-0 right-0 h-1 origin-left ${
|
|
175
280
|
!toast.customColor?.progress ? defaultStyle.progress : ""
|
|
176
281
|
}`}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"use client"
|
|
1
|
+
"use client";
|
|
2
2
|
|
|
3
3
|
// Import React to access state hooks and ReactNode type definitions
|
|
4
4
|
import * as React from "react";
|
|
@@ -8,62 +8,70 @@ export type ToastVariant = "default" | "success" | "error" | "warning" | "info"
|
|
|
8
8
|
|
|
9
9
|
// Interface defining all configurable parameters when triggering a toast notification
|
|
10
10
|
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
|
-
|
|
11
|
+
// Unique identifier for toast; auto-generated if omitted
|
|
12
|
+
id?: string;
|
|
13
|
+
// Primary header text or React component
|
|
14
|
+
title?: React.ReactNode;
|
|
15
|
+
// Secondary descriptive message or details
|
|
16
|
+
description?: React.ReactNode;
|
|
17
|
+
// Optional action button or interactive element
|
|
18
|
+
action?: React.ReactNode;
|
|
19
|
+
// Visual style preset (success, error, warning, info, default, custom)
|
|
20
|
+
variant?: ToastVariant;
|
|
21
|
+
// Individual lifespan in milliseconds; overrides the global layout default
|
|
22
|
+
duration?: number;
|
|
23
|
+
// Maximum number of duplicate stacks allowed for this toast (defaults to 5)
|
|
24
|
+
maxCount?: number;
|
|
25
|
+
// User-defined custom styling parameters for dynamic themes
|
|
26
|
+
customColor?: {
|
|
27
|
+
// Custom CSS background color (HEX, RGB, or HSL)
|
|
28
|
+
bg?: string;
|
|
29
|
+
// Custom text color
|
|
30
|
+
text?: string;
|
|
31
|
+
// Custom border stroke color
|
|
32
|
+
border?: string;
|
|
33
|
+
// Custom progress bar stroke color
|
|
34
|
+
progress?: string;
|
|
35
|
+
// Custom icon fill/stroke tint
|
|
36
|
+
icon?: string;
|
|
37
|
+
};
|
|
38
|
+
// Additional Tailwind or custom CSS classes applied to toast container
|
|
39
|
+
className?: string;
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
// Internal representation of an active toast item containing open state
|
|
41
43
|
export interface ToastItem extends ToastOptions {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
44
|
+
// Guaranteed string ID for DOM key mapping
|
|
45
|
+
id: string;
|
|
46
|
+
// Boolean flag controlling entrance and exit animations
|
|
47
|
+
open: boolean;
|
|
48
|
+
// Tracks duplicate trigger count
|
|
49
|
+
count: number;
|
|
50
|
+
// Indicates if maximum stack limit has been hit
|
|
51
|
+
maxReached?: boolean;
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
// Maximum number of visible toast cards on screen simultaneously
|
|
49
55
|
const TOAST_LIMIT = 5;
|
|
56
|
+
// Default maximum stack limit for duplicate toast triggers
|
|
57
|
+
const DEFAULT_MAX_COUNT = 5;
|
|
50
58
|
// Delay before removed toasts are completely purged from memory (allows exit transition)
|
|
51
|
-
const TOAST_REMOVE_DELAY =
|
|
59
|
+
const TOAST_REMOVE_DELAY = 0;
|
|
52
60
|
|
|
53
61
|
// Discriminated union type representing all possible reducer actions
|
|
54
|
-
type Action =
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
62
|
+
type Action =
|
|
63
|
+
// Adds a newly triggered toast to state
|
|
64
|
+
| { type: "ADD_TOAST"; toast: ToastItem }
|
|
65
|
+
// Modifies properties of an existing active toast
|
|
66
|
+
| { type: "UPDATE_TOAST"; toast: Partial<ToastItem> }
|
|
67
|
+
// Initiates dismiss sequence (triggers exit animation)
|
|
68
|
+
| { type: "DISMISS_TOAST"; toastId?: string }
|
|
69
|
+
// Purges toast object from memory after exit animation finishes
|
|
70
|
+
| { type: "REMOVE_TOAST"; toastId?: string };
|
|
63
71
|
|
|
64
72
|
// Structure of global toast memory state
|
|
65
73
|
interface State {
|
|
66
|
-
|
|
74
|
+
toasts: ToastItem[];
|
|
67
75
|
}
|
|
68
76
|
|
|
69
77
|
// Monotonically increasing counter for collision-free ID generation
|
|
@@ -71,8 +79,8 @@ let count = 0;
|
|
|
71
79
|
|
|
72
80
|
// Generates unique string identifiers for toast items
|
|
73
81
|
function genId(): string {
|
|
74
|
-
|
|
75
|
-
|
|
82
|
+
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
|
83
|
+
return count.toString();
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
// Map tracking active removal timers to prevent duplicate schedule queues
|
|
@@ -80,73 +88,108 @@ const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
|
80
88
|
|
|
81
89
|
// Schedule the hard removal of a dismissed toast after its exit animation completes
|
|
82
90
|
const addToRemoveQueue = (toastId: string) => {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
91
|
+
// If a timeout is already scheduled for this ID, skip to avoid duplicates
|
|
92
|
+
if (toastTimeouts.has(toastId)) return;
|
|
93
|
+
|
|
94
|
+
// Schedule state dispatch after delay
|
|
95
|
+
const timeout = setTimeout(() => {
|
|
96
|
+
// Clean up timeout reference from tracking map
|
|
97
|
+
toastTimeouts.delete(toastId);
|
|
98
|
+
// Dispatch removal action to purge from state
|
|
99
|
+
dispatch({ type: "REMOVE_TOAST", toastId });
|
|
100
|
+
}, TOAST_REMOVE_DELAY);
|
|
101
|
+
|
|
102
|
+
// Store reference in tracking map
|
|
103
|
+
toastTimeouts.set(toastId, timeout);
|
|
96
104
|
};
|
|
97
105
|
|
|
98
106
|
// Pure reducer function handling toast state transitions
|
|
99
107
|
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
|
-
return {
|
|
142
|
-
...state,
|
|
143
|
-
// Filter out target toast from state memory
|
|
144
|
-
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
default:
|
|
148
|
-
return state;
|
|
108
|
+
switch (action.type) {
|
|
109
|
+
case "ADD_TOAST": {
|
|
110
|
+
// Find existing active toast with identical title, description, and variant
|
|
111
|
+
const existingIndex = state.toasts.findIndex(
|
|
112
|
+
(t) =>
|
|
113
|
+
t.open &&
|
|
114
|
+
t.title === action.toast.title &&
|
|
115
|
+
t.description === action.toast.description &&
|
|
116
|
+
(t.variant || "default") === (action.toast.variant || "default")
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
if (existingIndex !== -1) {
|
|
120
|
+
const existing = state.toasts[existingIndex];
|
|
121
|
+
const maxLimit = existing.maxCount ?? action.toast.maxCount ?? DEFAULT_MAX_COUNT;
|
|
122
|
+
|
|
123
|
+
// Cap the count at maxLimit
|
|
124
|
+
const newCount = Math.min(existing.count + 1, maxLimit);
|
|
125
|
+
const maxReached = existing.count + 1 >= maxLimit;
|
|
126
|
+
|
|
127
|
+
const updatedToast: ToastItem = {
|
|
128
|
+
...existing,
|
|
129
|
+
...action.toast,
|
|
130
|
+
id: existing.id,
|
|
131
|
+
count: newCount,
|
|
132
|
+
maxReached,
|
|
133
|
+
open: true,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// Remove old instance and push updated version to the front
|
|
137
|
+
const rest = state.toasts.filter((t) => t.id !== existing.id);
|
|
138
|
+
return {
|
|
139
|
+
...state,
|
|
140
|
+
toasts: [updatedToast, ...rest].slice(0, TOAST_LIMIT),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
...state,
|
|
146
|
+
// Prepend new toast and enforce maximum visible limit
|
|
147
|
+
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
|
148
|
+
};
|
|
149
149
|
}
|
|
150
|
+
|
|
151
|
+
case "UPDATE_TOAST":
|
|
152
|
+
return {
|
|
153
|
+
...state,
|
|
154
|
+
// Map through toasts and merge updated properties onto target ID
|
|
155
|
+
toasts: state.toasts.map((t) =>
|
|
156
|
+
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
|
157
|
+
),
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
case "DISMISS_TOAST": {
|
|
161
|
+
const { toastId } = action;
|
|
162
|
+
|
|
163
|
+
// If a specific ID is provided, schedule removal for only that toast
|
|
164
|
+
if (toastId) {
|
|
165
|
+
addToRemoveQueue(toastId);
|
|
166
|
+
} else {
|
|
167
|
+
// Otherwise schedule removal for all currently open toasts
|
|
168
|
+
state.toasts.forEach((toast) => addToRemoveQueue(toast.id));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
...state,
|
|
173
|
+
// Mark target toasts as closed to trigger CSS fade-out
|
|
174
|
+
toasts: state.toasts.map((t) =>
|
|
175
|
+
t.id === toastId || toastId === undefined ? { ...t, open: false } : t
|
|
176
|
+
),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case "REMOVE_TOAST":
|
|
181
|
+
// Clear entire array if no specific ID passed
|
|
182
|
+
if (action.toastId === undefined) return { ...state, toasts: [] };
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
...state,
|
|
186
|
+
// Filter out target toast from state memory
|
|
187
|
+
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
default:
|
|
191
|
+
return state;
|
|
192
|
+
}
|
|
150
193
|
};
|
|
151
194
|
|
|
152
195
|
// Array of subscriber callbacks implementing the Observer pattern
|
|
@@ -157,58 +200,60 @@ let memoryState: State = { toasts: [] };
|
|
|
157
200
|
|
|
158
201
|
// Dispatches actions to state and notifies all registered React hook subscribers
|
|
159
202
|
function dispatch(action: Action) {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
203
|
+
// Update in-memory singleton state
|
|
204
|
+
memoryState = reducer(memoryState, action);
|
|
205
|
+
// Notify every mounted React component listener
|
|
206
|
+
listeners.forEach((listener) => listener(memoryState));
|
|
164
207
|
}
|
|
165
208
|
|
|
166
209
|
// Imperative toast function callable from anywhere (inside or outside React lifecycle)
|
|
167
210
|
export function toast(props: ToastOptions) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
211
|
+
// Use provided ID or generate a new unique identifier
|
|
212
|
+
const id = props.id || genId();
|
|
213
|
+
|
|
214
|
+
// Helper to dynamically update this specific toast
|
|
215
|
+
const update = (updatedProps: ToastOptions) =>
|
|
216
|
+
dispatch({ type: "UPDATE_TOAST", toast: { ...updatedProps, id } });
|
|
217
|
+
|
|
218
|
+
// Helper to dismiss this specific toast
|
|
219
|
+
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
|
220
|
+
|
|
221
|
+
// Dispatch action to push toast into visible queue
|
|
222
|
+
dispatch({
|
|
223
|
+
type: "ADD_TOAST",
|
|
224
|
+
toast: {
|
|
225
|
+
...props,
|
|
226
|
+
id,
|
|
227
|
+
open: true,
|
|
228
|
+
count: 1,
|
|
229
|
+
maxReached: false,
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// Return control object allowing caller to dismiss or update toast programmatically
|
|
234
|
+
return { id, dismiss, update };
|
|
190
235
|
}
|
|
191
236
|
|
|
192
237
|
// Custom React hook subscribing components to real-time toast updates
|
|
193
238
|
export function useToast() {
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
};
|
|
206
|
-
}, []); // Empty array ensures registration only happens on mount/unmount
|
|
207
|
-
|
|
208
|
-
// Expose current state, trigger function, and dismiss helper
|
|
209
|
-
return {
|
|
210
|
-
...state,
|
|
211
|
-
toast,
|
|
212
|
-
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
|
239
|
+
// Local state synced with singleton memory state
|
|
240
|
+
const [state, setState] = React.useState<State>(memoryState);
|
|
241
|
+
|
|
242
|
+
// Register listener on mount; unregister on unmount
|
|
243
|
+
React.useEffect(() => {
|
|
244
|
+
listeners.push(setState);
|
|
245
|
+
return () => {
|
|
246
|
+
const index = listeners.indexOf(setState);
|
|
247
|
+
if (index > -1) {
|
|
248
|
+
listeners.splice(index, 1);
|
|
249
|
+
}
|
|
213
250
|
};
|
|
251
|
+
}, []); // Empty array ensures registration only happens on mount/unmount
|
|
252
|
+
|
|
253
|
+
// Expose current state, trigger function, and dismiss helper
|
|
254
|
+
return {
|
|
255
|
+
...state,
|
|
256
|
+
toast,
|
|
257
|
+
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
|
258
|
+
};
|
|
214
259
|
}
|