@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marv3l/canopy-ui",
3
- "version": "1.0.4",
3
+ "version": "1.1.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": {
@@ -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 your semantic theme tokens
18
+ // Visual preset configurations mapped to semantic & dark-safe theme tokens
19
19
  const variantStyles: Record<
20
20
  string,
21
- { bg: string; border: string; text: string; progress: string; icon: any }
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
- text: "text-card-foreground",
27
- progress: "bg-foreground/20",
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
- text: "text-success",
34
- progress: "bg-success",
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
- text: "text-destructive",
41
- progress: "bg-destructive",
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
- text: "text-warning",
48
- progress: "bg-warning",
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
- text: "text-accent-foreground",
55
- progress: "bg-primary",
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-lg transition-all duration-200 backdrop-blur-sm ${
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="w-5 h-5 mt-0.5 shrink-0"
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 space-y-1">
154
- {toast.title && <div className="font-semibold leading-tight">{toast.title}</div>}
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 className="opacity-90 leading-relaxed text-xs">
157
- {toast.description}
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
- {toast.action && <div className="pt-1">{toast.action}</div>}
262
+
263
+ {/* Interactive Action Button Slot */}
264
+ {toast.action && <div className="pt-2">{toast.action}</div>}
161
265
  </div>
162
266
 
163
- {/* Close button */}
267
+ {/* Close Button */}
164
268
  <button
165
269
  onClick={onDismiss}
166
- className="p-1 rounded-md opacity-70 hover:opacity-100 hover:bg-foreground/10 transition-colors"
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
- // 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;
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
- // Guaranteed string ID for DOM key mapping
43
- id: string;
44
- // Boolean flag controlling entrance and exit animations
45
- open: boolean;
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 = 500;
59
+ const TOAST_REMOVE_DELAY = 0;
52
60
 
53
61
  // 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 };
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
- toasts: ToastItem[];
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
- count = (count + 1) % Number.MAX_SAFE_INTEGER;
75
- return count.toString();
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
- // 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);
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
- 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;
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
- // Update in-memory singleton state
161
- memoryState = reducer(memoryState, action);
162
- // Notify every mounted React component listener
163
- listeners.forEach((listener) => listener(memoryState));
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
- // 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
- });
187
-
188
- // Return control object allowing caller to dismiss or update toast programmatically
189
- return { id, dismiss, update };
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
- // 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
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
  }