@marv3l/canopy-ui 1.1.0 → 1.2.1

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/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ #!/usr/bin/env node
2
3
 
3
4
  // src/index.ts
4
5
  import { Command } from "commander";
@@ -13,28 +14,33 @@ import pc from "picocolors";
13
14
 
14
15
  // src/registry.ts
15
16
  var REGISTRY = {
16
- // Toast component registration
17
17
  toast: {
18
- name: "Custom Toast Notification System",
19
- // Packages to automatically install in consumer project
20
- dependencies: ["lucide-react", "clsx", "tailwind-merge"],
18
+ name: "Toast",
19
+ packageName: "@canopy-ui/toast",
20
+ // Your published npm package name
21
+ dependencies: ["lucide-react"],
21
22
  files: [
22
23
  {
23
- templatePath: "toast/toast.tsx",
24
- targetName: "toast.tsx"
25
- },
26
- {
27
- templatePath: "toast/use-toast.ts",
28
- targetName: "use-toast.ts"
24
+ targetName: "toast.tsx",
25
+ content: `"use client";
26
+
27
+ export * from "@canopy-ui/toast";`
29
28
  }
30
29
  ]
31
30
  }
32
- // Future components (dialog, sheet, dropdown) are added here
33
31
  };
34
32
 
35
33
  // src/commands/add.ts
36
34
  var __filename = fileURLToPath(import.meta.url);
37
35
  var __dirname = path.dirname(__filename);
36
+ async function detectPackageManager(projectRoot) {
37
+ if (await fs.pathExists(path.join(projectRoot, "pnpm-lock.yaml"))) return "pnpm";
38
+ if (await fs.pathExists(path.join(projectRoot, "yarn.lock"))) return "yarn";
39
+ if (await fs.pathExists(path.join(projectRoot, "bun.lockb")) || await fs.pathExists(path.join(projectRoot, "bun.lock"))) {
40
+ return "bun";
41
+ }
42
+ return "npm";
43
+ }
38
44
  async function add(componentKeys) {
39
45
  p.intro(pc.bgCyan(pc.black(" Canopy UI ")));
40
46
  let selected = componentKeys;
@@ -55,6 +61,7 @@ async function add(componentKeys) {
55
61
  selected = response;
56
62
  }
57
63
  const projectRoot = process.cwd();
64
+ const pkgManager = await detectPackageManager(projectRoot);
58
65
  const hasSrc = await fs.pathExists(path.join(projectRoot, "src"));
59
66
  const targetDir = hasSrc ? path.join(projectRoot, "src", "components", "ui") : path.join(projectRoot, "components", "ui");
60
67
  await fs.ensureDir(targetDir);
@@ -65,35 +72,58 @@ async function add(componentKeys) {
65
72
  p.log.error(`Component "${key}" was not found in registry.`);
66
73
  continue;
67
74
  }
68
- spinner2.start(`Copying ${meta.name} source files...`);
75
+ spinner2.start(`Setting up ${meta.name}...`);
76
+ const allDependencies = [
77
+ ...meta.packageName ? [meta.packageName] : [],
78
+ ...meta.dependencies || []
79
+ ];
80
+ if (allDependencies.length > 0) {
81
+ spinner2.message(
82
+ `Installing packages (${pc.cyan(pkgManager)}): ${pc.dim(allDependencies.join(", "))}...`
83
+ );
84
+ const installArgs = {
85
+ npm: ["install", ...allDependencies],
86
+ pnpm: ["add", ...allDependencies],
87
+ yarn: ["add", ...allDependencies],
88
+ bun: ["add", ...allDependencies]
89
+ }[pkgManager];
90
+ try {
91
+ await execa(pkgManager, installArgs, {
92
+ cwd: projectRoot,
93
+ stdio: "pipe"
94
+ });
95
+ } catch (err) {
96
+ spinner2.stop(pc.red(`Failed to install dependencies for ${meta.name}`));
97
+ p.log.error(String(err));
98
+ continue;
99
+ }
100
+ }
101
+ spinner2.message(`Creating component stub for ${meta.name}...`);
69
102
  for (const file of meta.files) {
70
- const srcPath = path.resolve(__dirname, "../templates", file.templatePath);
71
103
  const destPath = path.join(targetDir, file.targetName);
72
- if (await fs.pathExists(srcPath)) {
73
- await fs.copy(srcPath, destPath, { overwrite: true });
74
- } else {
75
- spinner2.stop(pc.red(`Template file missing: ${file.templatePath}`));
76
- p.log.warn(pc.dim(`Looked at path: ${srcPath}`));
77
- return;
104
+ if (file.templatePath) {
105
+ const srcPath = path.resolve(__dirname, "../templates", file.templatePath);
106
+ if (await fs.pathExists(srcPath)) {
107
+ await fs.copy(srcPath, destPath, { overwrite: true });
108
+ } else {
109
+ spinner2.stop(pc.red(`Template file missing: ${file.templatePath}`));
110
+ p.log.warn(pc.dim(`Looked at path: ${srcPath}`));
111
+ return;
112
+ }
113
+ } else if (file.content) {
114
+ await fs.writeFile(destPath, file.content.trim() + "\n", "utf-8");
78
115
  }
79
116
  }
80
- if (meta.dependencies && meta.dependencies.length > 0) {
81
- spinner2.message(
82
- `Installing required npm packages: ${meta.dependencies.join(", ")}...`
83
- );
84
- await execa("npm", ["install", ...meta.dependencies], {
85
- cwd: projectRoot
86
- });
87
- }
88
- spinner2.stop(pc.green(`\u2714 Added ${meta.name} into ${hasSrc ? "src/" : ""}components/ui/`));
117
+ const relativePath = `${hasSrc ? "src/" : ""}components/ui/`;
118
+ spinner2.stop(pc.green(`\u2714 Added ${meta.name} into ${relativePath}`));
89
119
  }
90
120
  p.outro(pc.green("All components successfully installed!"));
91
121
  }
92
122
 
93
123
  // src/index.ts
94
124
  var program = new Command();
95
- program.name("Canopy UI").description("Install custom modular UI components directly to your project").version("1.0.0");
96
- program.command("add").description("Add a component to your project").argument("[components...]", "Component identifiers (e.g., toast").action(async (components) => {
125
+ program.name("canopy-ui").description("Install custom modular UI components directly to your project").version("1.0.0");
126
+ program.command("add").description("Add a component to your project").argument("[components...]", "Component identifiers (e.g., toast)").action(async (components) => {
97
127
  await add(components);
98
128
  });
99
129
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marv3l/canopy-ui",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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,21 +1,14 @@
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 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: any;
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 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;
175
178
 
176
- // Reset lifespan timer whenever count or duration changes
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
- if (duration <= 0) return;
179
- 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(() => {
180
198
  onDismiss();
181
- }, duration);
182
- return () => clearTimeout(timer);
183
- }, [duration, toast.count, 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
+ };
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
- {/* Render icon if preset defines one */}
207
- {IconComponent && (
208
- <IconComponent
209
- className={`w-5 h-5 mt-0.5 shrink-0 ${defaultStyle.iconColor}`}
210
- style={{ color: toast.customColor?.icon }}
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
- {/* Animated Lifespan Progress Bar (Resets on stack increment) */}
276
- {duration > 0 && (
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 ${duration}ms linear forwards`,
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
- // 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
21
+ /** Optional custom identifier. If omitted, a collision-resistant UUID is generated. */
12
22
  id?: string;
13
- // Primary header text or React component
23
+ /** Primary title text or JSX element displayed in the toast header. */
14
24
  title?: React.ReactNode;
15
- // Secondary descriptive message or details
25
+ /** Secondary explanatory text or JSX node rendered below the title. */
16
26
  description?: React.ReactNode;
17
- // Optional action button or interactive element
27
+ /** Interactive action button or control rendered at the bottom of the toast. */
18
28
  action?: React.ReactNode;
19
- // Visual style preset (success, error, warning, info, default, custom)
29
+ /** Visual variant styling preset. Defaults to "default". */
20
30
  variant?: ToastVariant;
21
- // Individual lifespan in milliseconds; overrides the global layout default
31
+ /** Display duration in milliseconds before auto-dismissing. Set to 0 or Infinity to prevent auto-dismiss. */
22
32
  duration?: number;
23
- // Maximum number of duplicate stacks allowed for this toast (defaults to 5)
33
+ /** Maximum number of duplicate message stacks allowed for this specific toast (defaults to 5). */
24
34
  maxCount?: number;
25
- // User-defined custom styling parameters for dynamic themes
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
- // Additional Tailwind or custom CSS classes applied to toast container
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
- // 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. */
55
63
  const TOAST_LIMIT = 5;
56
- // Default maximum stack limit for duplicate toast triggers
64
+
65
+ /** Default duplicate stack cap if not overridden per toast. */
57
66
  const DEFAULT_MAX_COUNT = 5;
58
- // Delay before removed toasts are completely purged from memory (allows exit transition)
67
+
68
+ /** Delay in ms before unmounting a dismissed toast from the state tree. */
59
69
  const TOAST_REMOVE_DELAY = 0;
60
70
 
61
- // Discriminated union type representing all possible reducer actions
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
- // Monotonically increasing counter for collision-free ID generation
78
- let count = 0;
79
-
80
- // 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
+ */
81
89
  function genId(): string {
82
- count = (count + 1) % Number.MAX_SAFE_INTEGER;
83
- 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)}`;
84
94
  }
85
95
 
86
- // Map tracking active removal timers to prevent duplicate schedule queues
96
+ /** Tracks active unmount timers to prevent duplicate queue dispatches. */
87
97
  const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
88
98
 
89
- // 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
+ */
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
- // Pure reducer function handling toast state transitions
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
- // Find existing active toast with identical title, description, and variant
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
- // Remove old instance and push updated version to the front
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
- // If a specific ID is provided, schedule removal for only that toast
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
- // Array of subscriber callbacks implementing the Observer pattern
196
- const listeners: Array<(state: State) => void> = [];
211
+ // -----------------------------------------------------------------------------
212
+ // Store Listeners & Dispatch Dispatcher
213
+ // -----------------------------------------------------------------------------
197
214
 
198
- // Singleton state variable preserving toast state across entire application
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
- // 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
+ */
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
- // Custom React hook subscribing components to real-time toast updates
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
- }, []); // Empty array ensures registration only happens on mount/unmount
336
+ }, []);
252
337
 
253
- // Expose current state, trigger function, and dismiss helper
254
338
  return {
255
339
  ...state,
256
340
  toast,