@marv3l/canopy-ui 1.1.0 → 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 +74 -34
- package/templates/toast/use-toast.ts +157 -73
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,21 +1,14 @@
|
|
|
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 semantic & dark-safe theme tokens
|
|
19
12
|
const variantStyles: Record<
|
|
20
13
|
string,
|
|
21
14
|
{
|
|
@@ -26,7 +19,7 @@ const variantStyles: Record<
|
|
|
26
19
|
progress: string;
|
|
27
20
|
iconColor: string;
|
|
28
21
|
badge: string;
|
|
29
|
-
icon:
|
|
22
|
+
icon: LucideIcon | null;
|
|
30
23
|
}
|
|
31
24
|
> = {
|
|
32
25
|
default: {
|
|
@@ -79,9 +72,18 @@ const variantStyles: Record<
|
|
|
79
72
|
badge: "bg-sky-100 text-sky-900 dark:bg-sky-900/80 dark:text-sky-100 border-sky-300 dark:border-sky-700",
|
|
80
73
|
icon: Info,
|
|
81
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
|
+
},
|
|
82
85
|
};
|
|
83
86
|
|
|
84
|
-
// Root Toaster Component placed into layout.tsx
|
|
85
87
|
export function Toaster({ defaultDuration = 4000, position = "top-center" }: ToasterProps) {
|
|
86
88
|
const { toasts, dismiss } = useToast();
|
|
87
89
|
|
|
@@ -161,7 +163,6 @@ export function Toaster({ defaultDuration = 4000, position = "top-center" }: Toa
|
|
|
161
163
|
);
|
|
162
164
|
}
|
|
163
165
|
|
|
164
|
-
// Atomic Toast Card Component
|
|
165
166
|
function ToastElement({
|
|
166
167
|
toast,
|
|
167
168
|
defaultDuration,
|
|
@@ -171,44 +172,84 @@ function ToastElement({
|
|
|
171
172
|
defaultDuration: number;
|
|
172
173
|
onDismiss: () => void;
|
|
173
174
|
}) {
|
|
174
|
-
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;
|
|
175
178
|
|
|
176
|
-
|
|
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);
|
|
183
|
+
|
|
184
|
+
// Update remaining ref on prop changes without triggering synchronous setState re-renders
|
|
177
185
|
React.useEffect(() => {
|
|
178
|
-
|
|
179
|
-
|
|
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(() => {
|
|
180
198
|
onDismiss();
|
|
181
|
-
},
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
};
|
|
184
218
|
|
|
185
219
|
const variant = toast.variant || "default";
|
|
186
220
|
const defaultStyle = variantStyles[variant] || variantStyles.default;
|
|
187
|
-
const IconComponent = defaultStyle.icon;
|
|
221
|
+
const IconComponent = toast.icon !== undefined ? null : defaultStyle.icon;
|
|
188
222
|
|
|
189
|
-
// 1. Only build inline styles for values that are explicitly provided
|
|
190
223
|
const customInlineStyle: React.CSSProperties = {};
|
|
191
224
|
if (toast.customColor?.bg) customInlineStyle.backgroundColor = toast.customColor.bg;
|
|
192
225
|
if (toast.customColor?.border) customInlineStyle.borderColor = toast.customColor.border;
|
|
193
226
|
if (toast.customColor?.text) customInlineStyle.color = toast.customColor.text;
|
|
194
227
|
|
|
195
|
-
// 2. Prevent default Tailwind classes from overriding user custom classes or inline styles
|
|
196
228
|
const userHasBg = Boolean(toast.customColor?.bg || toast.className?.match(/(?:^|\s)bg-/));
|
|
197
229
|
const userHasBorder = Boolean(toast.customColor?.border || toast.className?.match(/(?:^|\s)border-/));
|
|
198
230
|
|
|
231
|
+
const isError = variant === "error";
|
|
232
|
+
|
|
199
233
|
return (
|
|
200
234
|
<div
|
|
235
|
+
role={isError ? "alert" : "status"}
|
|
236
|
+
aria-live={isError ? "assertive" : "polite"}
|
|
201
237
|
style={customInlineStyle}
|
|
238
|
+
onMouseEnter={handleMouseEnter}
|
|
239
|
+
onMouseLeave={handleMouseLeave}
|
|
202
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 ${
|
|
203
241
|
!userHasBg ? defaultStyle.bg : ""
|
|
204
242
|
} ${!userHasBorder ? defaultStyle.border : ""} ${toast.className || ""}`}
|
|
205
243
|
>
|
|
206
|
-
{
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
+
)
|
|
212
253
|
)}
|
|
213
254
|
|
|
214
255
|
{/* Toast Content Area */}
|
|
@@ -238,7 +279,6 @@ function ToastElement({
|
|
|
238
279
|
)}
|
|
239
280
|
</div>
|
|
240
281
|
|
|
241
|
-
{/* Collapsible/Expanding Description Wrapper */}
|
|
242
282
|
{toast.description && (
|
|
243
283
|
<div
|
|
244
284
|
className="grid overflow-hidden"
|
|
@@ -260,28 +300,28 @@ function ToastElement({
|
|
|
260
300
|
</div>
|
|
261
301
|
)}
|
|
262
302
|
|
|
263
|
-
{/* Interactive Action Button Slot */}
|
|
264
303
|
{toast.action && <div className="pt-2">{toast.action}</div>}
|
|
265
304
|
</div>
|
|
266
305
|
|
|
267
|
-
{/* Close Button */}
|
|
268
306
|
<button
|
|
269
307
|
onClick={onDismiss}
|
|
308
|
+
aria-label="Close toast"
|
|
270
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"
|
|
271
310
|
>
|
|
272
311
|
<X className="w-4 h-4" />
|
|
273
312
|
</button>
|
|
274
313
|
|
|
275
|
-
{/*
|
|
276
|
-
{
|
|
314
|
+
{/* Progress Bar resets animation on each state change */}
|
|
315
|
+
{showProgress && isAutoDismissible && (
|
|
277
316
|
<div
|
|
278
|
-
key={toast.count}
|
|
317
|
+
key={`${toast.id}-${toast.variant}-${toast.count}`}
|
|
279
318
|
className={`absolute bottom-0 left-0 right-0 h-1 origin-left ${
|
|
280
319
|
!toast.customColor?.progress ? defaultStyle.progress : ""
|
|
281
320
|
}`}
|
|
282
321
|
style={{
|
|
283
322
|
backgroundColor: toast.customColor?.progress,
|
|
284
|
-
animation: `toast-progress ${
|
|
323
|
+
animation: `toast-progress ${activeDuration}ms linear forwards`,
|
|
324
|
+
animationPlayState: isPaused ? "paused" : "running",
|
|
285
325
|
}}
|
|
286
326
|
/>
|
|
287
327
|
)}
|
|
@@ -1,113 +1,134 @@
|
|
|
1
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
|
-
|
|
21
|
+
/** Optional custom identifier. If omitted, a collision-resistant UUID is generated. */
|
|
12
22
|
id?: string;
|
|
13
|
-
|
|
23
|
+
/** Primary title text or JSX element displayed in the toast header. */
|
|
14
24
|
title?: React.ReactNode;
|
|
15
|
-
|
|
25
|
+
/** Secondary explanatory text or JSX node rendered below the title. */
|
|
16
26
|
description?: React.ReactNode;
|
|
17
|
-
|
|
27
|
+
/** Interactive action button or control rendered at the bottom of the toast. */
|
|
18
28
|
action?: React.ReactNode;
|
|
19
|
-
|
|
29
|
+
/** Visual variant styling preset. Defaults to "default". */
|
|
20
30
|
variant?: ToastVariant;
|
|
21
|
-
|
|
31
|
+
/** Display duration in milliseconds before auto-dismissing. Set to 0 or Infinity to prevent auto-dismiss. */
|
|
22
32
|
duration?: number;
|
|
23
|
-
|
|
33
|
+
/** Maximum number of duplicate message stacks allowed for this specific toast (defaults to 5). */
|
|
24
34
|
maxCount?: number;
|
|
25
|
-
|
|
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. */
|
|
26
40
|
customColor?: {
|
|
27
|
-
// Custom CSS background color (HEX, RGB, or HSL)
|
|
28
41
|
bg?: string;
|
|
29
|
-
// Custom text color
|
|
30
42
|
text?: string;
|
|
31
|
-
// Custom border stroke color
|
|
32
43
|
border?: string;
|
|
33
|
-
// Custom progress bar stroke color
|
|
34
44
|
progress?: string;
|
|
35
|
-
// Custom icon fill/stroke tint
|
|
36
45
|
icon?: string;
|
|
37
46
|
};
|
|
38
|
-
|
|
47
|
+
/** Additional Tailwind or CSS class names to apply to the root toast card container. */
|
|
39
48
|
className?: string;
|
|
40
49
|
}
|
|
41
50
|
|
|
42
|
-
// Internal representation of an active toast item containing open state
|
|
43
51
|
export interface ToastItem extends ToastOptions {
|
|
44
|
-
// Guaranteed string ID for DOM key mapping
|
|
45
52
|
id: string;
|
|
46
|
-
// Boolean flag controlling entrance and exit animations
|
|
47
53
|
open: boolean;
|
|
48
|
-
// Tracks duplicate trigger count
|
|
49
54
|
count: number;
|
|
50
|
-
// Indicates if maximum stack limit has been hit
|
|
51
55
|
maxReached?: boolean;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
//
|
|
58
|
+
// -----------------------------------------------------------------------------
|
|
59
|
+
// Constants & Configuration
|
|
60
|
+
// -----------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
/** Maximum number of simultaneous toasts rendered on screen at any time. */
|
|
55
63
|
const TOAST_LIMIT = 5;
|
|
56
|
-
|
|
64
|
+
|
|
65
|
+
/** Default duplicate stack cap if not overridden per toast. */
|
|
57
66
|
const DEFAULT_MAX_COUNT = 5;
|
|
58
|
-
|
|
67
|
+
|
|
68
|
+
/** Delay in ms before unmounting a dismissed toast from the state tree. */
|
|
59
69
|
const TOAST_REMOVE_DELAY = 0;
|
|
60
70
|
|
|
61
|
-
//
|
|
71
|
+
// -----------------------------------------------------------------------------
|
|
72
|
+
// Action Types & State Management
|
|
73
|
+
// -----------------------------------------------------------------------------
|
|
74
|
+
|
|
62
75
|
type Action =
|
|
63
|
-
// Adds a newly triggered toast to state
|
|
64
76
|
| { type: "ADD_TOAST"; toast: ToastItem }
|
|
65
|
-
// Modifies properties of an existing active toast
|
|
66
77
|
| { type: "UPDATE_TOAST"; toast: Partial<ToastItem> }
|
|
67
|
-
// Initiates dismiss sequence (triggers exit animation)
|
|
68
78
|
| { type: "DISMISS_TOAST"; toastId?: string }
|
|
69
|
-
// Purges toast object from memory after exit animation finishes
|
|
70
79
|
| { type: "REMOVE_TOAST"; toastId?: string };
|
|
71
80
|
|
|
72
|
-
// Structure of global toast memory state
|
|
73
81
|
interface State {
|
|
74
82
|
toasts: ToastItem[];
|
|
75
83
|
}
|
|
76
84
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Generates a collision-resistant unique identifier.
|
|
87
|
+
* Uses native crypto.randomUUID when available, falling back to high-resolution timestamps.
|
|
88
|
+
*/
|
|
81
89
|
function genId(): string {
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
|
91
|
+
return crypto.randomUUID();
|
|
92
|
+
}
|
|
93
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
84
94
|
}
|
|
85
95
|
|
|
86
|
-
|
|
96
|
+
/** Tracks active unmount timers to prevent duplicate queue dispatches. */
|
|
87
97
|
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
|
88
98
|
|
|
89
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Schedules a toast for removal from memory after dismissal animations complete.
|
|
101
|
+
*/
|
|
90
102
|
const addToRemoveQueue = (toastId: string) => {
|
|
91
|
-
// If a timeout is already scheduled for this ID, skip to avoid duplicates
|
|
92
103
|
if (toastTimeouts.has(toastId)) return;
|
|
93
104
|
|
|
94
|
-
// Schedule state dispatch after delay
|
|
95
105
|
const timeout = setTimeout(() => {
|
|
96
|
-
// Clean up timeout reference from tracking map
|
|
97
106
|
toastTimeouts.delete(toastId);
|
|
98
|
-
// Dispatch removal action to purge from state
|
|
99
107
|
dispatch({ type: "REMOVE_TOAST", toastId });
|
|
100
108
|
}, TOAST_REMOVE_DELAY);
|
|
101
109
|
|
|
102
|
-
// Store reference in tracking map
|
|
103
110
|
toastTimeouts.set(toastId, timeout);
|
|
104
111
|
};
|
|
105
112
|
|
|
106
|
-
|
|
113
|
+
/**
|
|
114
|
+
* Main state reducer managing insertion, deduplication stacking, updates, and dismissals.
|
|
115
|
+
*/
|
|
107
116
|
export const reducer = (state: State, action: Action): State => {
|
|
108
117
|
switch (action.type) {
|
|
109
118
|
case "ADD_TOAST": {
|
|
110
|
-
//
|
|
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
|
|
111
132
|
const existingIndex = state.toasts.findIndex(
|
|
112
133
|
(t) =>
|
|
113
134
|
t.open &&
|
|
@@ -133,7 +154,7 @@ export const reducer = (state: State, action: Action): State => {
|
|
|
133
154
|
open: true,
|
|
134
155
|
};
|
|
135
156
|
|
|
136
|
-
//
|
|
157
|
+
// Move updated toast to the top of the stack
|
|
137
158
|
const rest = state.toasts.filter((t) => t.id !== existing.id);
|
|
138
159
|
return {
|
|
139
160
|
...state,
|
|
@@ -141,9 +162,9 @@ export const reducer = (state: State, action: Action): State => {
|
|
|
141
162
|
};
|
|
142
163
|
}
|
|
143
164
|
|
|
165
|
+
// 3. New Toast: Prepend to list and truncate at TOAST_LIMIT
|
|
144
166
|
return {
|
|
145
167
|
...state,
|
|
146
|
-
// Prepend new toast and enforce maximum visible limit
|
|
147
168
|
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
|
148
169
|
};
|
|
149
170
|
}
|
|
@@ -151,7 +172,6 @@ export const reducer = (state: State, action: Action): State => {
|
|
|
151
172
|
case "UPDATE_TOAST":
|
|
152
173
|
return {
|
|
153
174
|
...state,
|
|
154
|
-
// Map through toasts and merge updated properties onto target ID
|
|
155
175
|
toasts: state.toasts.map((t) =>
|
|
156
176
|
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
|
157
177
|
),
|
|
@@ -160,17 +180,16 @@ export const reducer = (state: State, action: Action): State => {
|
|
|
160
180
|
case "DISMISS_TOAST": {
|
|
161
181
|
const { toastId } = action;
|
|
162
182
|
|
|
163
|
-
//
|
|
183
|
+
// Queue removal for specific toast or all active toasts
|
|
164
184
|
if (toastId) {
|
|
165
185
|
addToRemoveQueue(toastId);
|
|
166
186
|
} else {
|
|
167
|
-
// Otherwise schedule removal for all currently open toasts
|
|
168
187
|
state.toasts.forEach((toast) => addToRemoveQueue(toast.id));
|
|
169
188
|
}
|
|
170
189
|
|
|
190
|
+
// Mark toast as closed to trigger exit transitions
|
|
171
191
|
return {
|
|
172
192
|
...state,
|
|
173
|
-
// Mark target toasts as closed to trigger CSS fade-out
|
|
174
193
|
toasts: state.toasts.map((t) =>
|
|
175
194
|
t.id === toastId || toastId === undefined ? { ...t, open: false } : t
|
|
176
195
|
),
|
|
@@ -178,12 +197,9 @@ export const reducer = (state: State, action: Action): State => {
|
|
|
178
197
|
}
|
|
179
198
|
|
|
180
199
|
case "REMOVE_TOAST":
|
|
181
|
-
// Clear entire array if no specific ID passed
|
|
182
200
|
if (action.toastId === undefined) return { ...state, toasts: [] };
|
|
183
|
-
|
|
184
201
|
return {
|
|
185
202
|
...state,
|
|
186
|
-
// Filter out target toast from state memory
|
|
187
203
|
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
|
188
204
|
};
|
|
189
205
|
|
|
@@ -192,33 +208,33 @@ export const reducer = (state: State, action: Action): State => {
|
|
|
192
208
|
}
|
|
193
209
|
};
|
|
194
210
|
|
|
195
|
-
//
|
|
196
|
-
|
|
211
|
+
// -----------------------------------------------------------------------------
|
|
212
|
+
// Store Listeners & Dispatch Dispatcher
|
|
213
|
+
// -----------------------------------------------------------------------------
|
|
197
214
|
|
|
198
|
-
|
|
215
|
+
const listeners: Array<(state: State) => void> = [];
|
|
199
216
|
let memoryState: State = { toasts: [] };
|
|
200
217
|
|
|
201
|
-
// Dispatches actions to state and notifies all registered React hook subscribers
|
|
202
218
|
function dispatch(action: Action) {
|
|
203
|
-
// Update in-memory singleton state
|
|
204
219
|
memoryState = reducer(memoryState, action);
|
|
205
|
-
// Notify every mounted React component listener
|
|
206
220
|
listeners.forEach((listener) => listener(memoryState));
|
|
207
221
|
}
|
|
208
222
|
|
|
209
|
-
//
|
|
223
|
+
// -----------------------------------------------------------------------------
|
|
224
|
+
// Core Toast Dispatcher & Convenience Helpers
|
|
225
|
+
// -----------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Base dispatcher function to spawn or update a toast alert.
|
|
229
|
+
*/
|
|
210
230
|
export function toast(props: ToastOptions) {
|
|
211
|
-
// Use provided ID or generate a new unique identifier
|
|
212
231
|
const id = props.id || genId();
|
|
213
232
|
|
|
214
|
-
// Helper to dynamically update this specific toast
|
|
215
233
|
const update = (updatedProps: ToastOptions) =>
|
|
216
234
|
dispatch({ type: "UPDATE_TOAST", toast: { ...updatedProps, id } });
|
|
217
235
|
|
|
218
|
-
// Helper to dismiss this specific toast
|
|
219
236
|
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
|
220
237
|
|
|
221
|
-
// Dispatch action to push toast into visible queue
|
|
222
238
|
dispatch({
|
|
223
239
|
type: "ADD_TOAST",
|
|
224
240
|
toast: {
|
|
@@ -230,16 +246,85 @@ export function toast(props: ToastOptions) {
|
|
|
230
246
|
},
|
|
231
247
|
});
|
|
232
248
|
|
|
233
|
-
// Return control object allowing caller to dismiss or update toast programmatically
|
|
234
249
|
return { id, dismiss, update };
|
|
235
250
|
}
|
|
236
251
|
|
|
237
|
-
|
|
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
|
+
});
|
|
314
|
+
|
|
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
|
+
*/
|
|
238
325
|
export function useToast() {
|
|
239
|
-
// Local state synced with singleton memory state
|
|
240
326
|
const [state, setState] = React.useState<State>(memoryState);
|
|
241
327
|
|
|
242
|
-
// Register listener on mount; unregister on unmount
|
|
243
328
|
React.useEffect(() => {
|
|
244
329
|
listeners.push(setState);
|
|
245
330
|
return () => {
|
|
@@ -248,9 +333,8 @@ export function useToast() {
|
|
|
248
333
|
listeners.splice(index, 1);
|
|
249
334
|
}
|
|
250
335
|
};
|
|
251
|
-
}, []);
|
|
336
|
+
}, []);
|
|
252
337
|
|
|
253
|
-
// Expose current state, trigger function, and dismiss helper
|
|
254
338
|
return {
|
|
255
339
|
...state,
|
|
256
340
|
toast,
|