@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 CHANGED
@@ -8,7 +8,7 @@ A modern npm package for building beautiful, customizable user interfaces.
8
8
 
9
9
  <br />
10
10
 
11
- [![npm](https://img.shields.io/badge/npm-CB3837?style=for-the-badge\&logo=npm\&logoColor=white)](https://www.npmjs.com/package/canopy-ui)
11
+ [![npm](https://img.shields.io/badge/npm-CB3837?style=for-the-badge\&logo=npm\&logoColor=white)](https://www.npmjs.com/package/@marv3l/canopy-ui)
12
12
  [![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?style=for-the-badge\&logo=typescript\&logoColor=white)](https://www.typescriptlang.org/)
13
13
  [![React](https://img.shields.io/badge/React-20232A?style=for-the-badge\&logo=react\&logoColor=61DAFB)](https://react.dev/)
14
14
  [![Next.js](https://img.shields.io/badge/Next.js-000000?style=for-the-badge\&logo=nextdotjs\&logoColor=white)](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,6 +1,6 @@
1
1
  {
2
2
  "name": "@marv3l/canopy-ui",
3
- "version": "1.0.4",
3
+ "version": "1.2.0",
4
4
  "description": "An accessible, themable React UI component library built for fast-moving web applications.",
5
5
  "main": "./dist/index.js",
6
6
  "bin": {
@@ -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
- // Import semantic SVG icons from lucide-react
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
- { bg: string; border: string; text: string; progress: string; icon: any }
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
- text: "text-card-foreground",
27
- progress: "bg-foreground/20",
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
- text: "text-success",
34
- progress: "bg-success",
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
- text: "text-destructive",
41
- progress: "bg-destructive",
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
- text: "text-warning",
48
- progress: "bg-warning",
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
- text: "text-accent-foreground",
55
- progress: "bg-primary",
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 duration = toast.duration ?? defaultDuration;
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
- if (duration <= 0) return;
114
- const timer = setTimeout(() => {
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
- }, duration);
117
- return () => clearTimeout(timer);
118
- }, [duration, onDismiss]);
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
- const userHasText = Boolean(toast.customColor?.text || toast.className?.match(/(?:^|\s)text-/));
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
- className={`pointer-events-auto relative overflow-hidden flex items-start gap-3 w-full p-4 rounded-[var(--radius-lg,0.625rem)] border shadow-lg transition-all duration-200 backdrop-blur-sm ${
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
- {/* Render icon if preset defines one */}
145
- {IconComponent && (
146
- <IconComponent
147
- className="w-5 h-5 mt-0.5 shrink-0"
148
- style={{ color: toast.customColor?.icon }}
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 space-y-1">
154
- {toast.title && <div className="font-semibold leading-tight">{toast.title}</div>}
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 className="opacity-90 leading-relaxed text-xs">
157
- {toast.description}
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
- {toast.action && <div className="pt-1">{toast.action}</div>}
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
- className="p-1 rounded-md opacity-70 hover:opacity-100 hover:bg-foreground/10 transition-colors"
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
- {/* Animated Lifespan Progress Bar */}
172
- {duration > 0 && (
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 ${duration}ms linear forwards`,
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
- // Define the supported visual preset types for toast notifications
7
- export type ToastVariant = "default" | "success" | "error" | "warning" | "info" | "custom";
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
- // 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
- // User-defined custom styling parameters for dynamic themes
24
- customColor?: {
25
- // Custom CSS background color (HEX, RGB, or HSL)
26
- bg?: string;
27
- // Custom text color
28
- text?: string;
29
- // Custom border stroke color
30
- border?: string;
31
- //Custom progress bar stroke color
32
- progress?: string;
33
- // Custom icon fill/stroke tint
34
- icon?: string;
35
- };
36
- // Additional Tailwind or custom CSS classes applied to toast container
37
- className?: string;
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
- // Guaranteed string ID for DOM key mapping
43
- id: string;
44
- // Boolean flag controlling entrance and exit animations
45
- open: boolean;
52
+ id: string;
53
+ open: boolean;
54
+ count: number;
55
+ maxReached?: boolean;
46
56
  }
47
57
 
48
- // Maximum number of visible toast cards on screen simultaneously
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
- // Delay before removed toasts are completely purged from memory (allows exit transition)
51
- const TOAST_REMOVE_DELAY = 500;
52
-
53
- // Discriminated union type representing all possible reducer actions
54
- type Action =
55
- // Adds a newly triggered toast to state
56
- | { type: "ADD_TOAST"; toast: ToastItem }
57
- // Modifies properties of an existing active toast
58
- | { type: "UPDATE_TOAST"; toast: Partial<ToastItem> }
59
- // Initiates dismiss sequence (triggers exit animation)
60
- | { type: "DISMISS_TOAST"; toastId?: string }
61
- // Purges toast object from memory after exit animation finishes
62
- | { type: "REMOVE_TOAST"; toastId?: string };
63
-
64
- // Structure of global toast memory state
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
- toasts: ToastItem[];
82
+ toasts: ToastItem[];
67
83
  }
68
84
 
69
- // Monotonically increasing counter for collision-free ID generation
70
- let count = 0;
71
-
72
- // Generates unique string identifiers for toast items
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
- count = (count + 1) % Number.MAX_SAFE_INTEGER;
75
- return count.toString();
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
- // Map tracking active removal timers to prevent duplicate schedule queues
96
+ /** Tracks active unmount timers to prevent duplicate queue dispatches. */
79
97
  const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
80
98
 
81
- // Schedule the hard removal of a dismissed toast after its exit animation completes
99
+ /**
100
+ * Schedules a toast for removal from memory after dismissal animations complete.
101
+ */
82
102
  const addToRemoveQueue = (toastId: string) => {
83
- // If a timeout is already scheduled for this ID, skip to avoid duplicates
84
- if (toastTimeouts.has(toastId)) return;
85
-
86
- // Schedule state dispatch after delay
87
- const timeout = setTimeout(() => {
88
- // Clean up timeout reference from tracking map
89
- toastTimeouts.delete(toastId);
90
- // Dispatch removal action to purge from state
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
- // Pure reducer function handling toast state transitions
113
+ /**
114
+ * Main state reducer managing insertion, deduplication stacking, updates, and dismissals.
115
+ */
99
116
  export const reducer = (state: State, action: Action): State => {
100
- switch (action.type) {
101
- case "ADD_TOAST":
102
- return {
103
- ...state,
104
- // Prepend new toast and enforce maximum visible limit
105
- toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
106
- };
107
-
108
- case "UPDATE_TOAST":
109
- return {
110
- ...state,
111
- // Map through toasts and merge updated properties onto target ID
112
- toasts: state.toasts.map((t) =>
113
- t.id === action.toast.id ? { ...t, ...action.toast } : t
114
- ),
115
- };
116
-
117
- case "DISMISS_TOAST": {
118
- const { toastId } = action;
119
-
120
- // If a specific ID is provided, schedule removal for only that toast
121
- if (toastId) {
122
- addToRemoveQueue(toastId);
123
- } else {
124
- // Otherwise schedule removal for all currently open toasts
125
- state.toasts.forEach((toast) => addToRemoveQueue(toast.id));
126
- }
127
-
128
- return {
129
- ...state,
130
- // Mark target toasts as closed to trigger CSS fade-out
131
- toasts: state.toasts.map((t) =>
132
- t.id === toastId || toastId === undefined ? { ...t, open: false } : t
133
- ),
134
- };
135
- }
136
-
137
- case "REMOVE_TOAST":
138
- // Clear entire array if no specific ID passed
139
- if (action.toastId === undefined) return { ...state, toasts: [] };
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;
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
- // Array of subscriber callbacks implementing the Observer pattern
153
- const listeners: Array<(state: State) => void> = [];
211
+ // -----------------------------------------------------------------------------
212
+ // Store Listeners & Dispatch Dispatcher
213
+ // -----------------------------------------------------------------------------
154
214
 
155
- // Singleton state variable preserving toast state across entire application
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
- // Update in-memory singleton state
161
- memoryState = reducer(memoryState, action);
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
- // Imperative toast function callable from anywhere (inside or outside React lifecycle)
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
- // Use provided ID or generate a new unique identifier
169
- const id = props.id || genId();
170
-
171
- // Helper to dynamically update this specific toast
172
- const update = (updatedProps: ToastOptions) =>
173
- dispatch({ type: "UPDATE_TOAST", toast: { ...updatedProps, id } });
174
-
175
- // Helper to dismiss this specific toast
176
- const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
177
-
178
- // Dispatch action to push toast into visible queue
179
- dispatch({
180
- type: "ADD_TOAST",
181
- toast: {
182
- ...props,
183
- id,
184
- open: true,
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
- // Return control object allowing caller to dismiss or update toast programmatically
189
- return { id, dismiss, update };
249
+ return { id, dismiss, update };
190
250
  }
191
251
 
192
- // Custom React hook subscribing components to real-time toast updates
193
- export function useToast() {
194
- // Local state synced with singleton memory state
195
- const [state, setState] = React.useState<State>(memoryState);
196
-
197
- // Register listener on mount; unregister on unmount
198
- React.useEffect(() => {
199
- listeners.push(setState);
200
- return () => {
201
- const index = listeners.indexOf(setState);
202
- if (index > -1) {
203
- listeners.splice(index, 1);
204
- }
205
- };
206
- }, []); // Empty array ensures registration only happens on mount/unmount
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
- // Expose current state, trigger function, and dismiss helper
209
- return {
210
- ...state,
211
- toast,
212
- dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
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
  }