@marv3l/canopy-ui 1.2.4 → 1.2.6

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/dist/index.js CHANGED
@@ -21,10 +21,6 @@ var REGISTRY = {
21
21
  {
22
22
  targetName: "toast.tsx",
23
23
  templatePath: "toast/toast.tsx"
24
- },
25
- {
26
- targetName: "use-toast.tsx",
27
- templatePath: "toast/use-toast.ts"
28
24
  }
29
25
  ]
30
26
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marv3l/canopy-ui",
3
- "version": "1.2.4",
3
+ "version": "1.2.6",
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,14 +1,82 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { CheckCircle2, AlertCircle, AlertTriangle, Info, Loader2, X, LucideIcon } from "lucide-react";
5
- import { useToast, ToastItem } from "./use-toast";
4
+ import {
5
+ CheckCircle2,
6
+ AlertCircle,
7
+ AlertTriangle,
8
+ Info,
9
+ Loader2,
10
+ X,
11
+ type LucideIcon,
12
+ } from "lucide-react";
13
+
14
+ // =============================================================================
15
+ // TYPES & INTERFACES
16
+ // =============================================================================
17
+
18
+ export type ToastVariant =
19
+ | "default"
20
+ | "success"
21
+ | "error"
22
+ | "warning"
23
+ | "info"
24
+ | "custom"
25
+ | "loading";
26
+
27
+ export interface ToastOptions {
28
+ /** Optional custom identifier. If omitted, a collision-resistant UUID is generated. */
29
+ id?: string;
30
+ /** Primary title text or JSX element displayed in the toast header. */
31
+ title?: React.ReactNode;
32
+ /** Secondary explanatory text or JSX node rendered below the title. */
33
+ description?: React.ReactNode;
34
+ /** Interactive action button or control rendered at the bottom of the toast. */
35
+ action?: React.ReactNode;
36
+ /** Visual variant styling preset. Defaults to "default". */
37
+ variant?: ToastVariant;
38
+ /** Display duration in milliseconds before auto-dismissing. Set to 0 or Infinity to prevent auto-dismiss. */
39
+ duration?: number;
40
+ /** Maximum number of duplicate message stacks allowed for this specific toast (defaults to 5). */
41
+ maxCount?: number;
42
+ /** Explicitly toggle the animated bottom progress bar. Defaults to true when auto-dismissible. */
43
+ showProgress?: boolean;
44
+ /** Optional custom icon node to override the variant preset icon. */
45
+ icon?: React.ReactNode;
46
+ /** Custom inline token styling overrides for background, border, text, progress bar, and icon. */
47
+ customColor?: {
48
+ bg?: string;
49
+ text?: string;
50
+ border?: string;
51
+ progress?: string;
52
+ icon?: string;
53
+ };
54
+ /** Additional Tailwind or CSS class names to apply to the root toast card container. */
55
+ className?: string;
56
+ }
57
+
58
+ export interface ToastItem extends ToastOptions {
59
+ id: string;
60
+ open: boolean;
61
+ count: number;
62
+ maxReached?: boolean;
63
+ }
6
64
 
7
65
  export interface ToasterProps {
8
66
  defaultDuration?: number;
9
- position?: "top-right" | "bottom-right" | "top-center" | "bottom-center" | "top-left" | "bottom-left";
67
+ position?:
68
+ | "top-right"
69
+ | "bottom-right"
70
+ | "top-center"
71
+ | "bottom-center"
72
+ | "top-left"
73
+ | "bottom-left";
10
74
  }
11
75
 
76
+ // =============================================================================
77
+ // VARIANT STYLING CONFIGURATION
78
+ // =============================================================================
79
+
12
80
  const variantStyles: Record<
13
81
  string,
14
82
  {
@@ -29,7 +97,8 @@ const variantStyles: Record<
29
97
  description: "text-neutral-600 dark:text-neutral-200",
30
98
  progress: "bg-neutral-900/20 dark:bg-neutral-400",
31
99
  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",
100
+ badge:
101
+ "bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-100 border-neutral-300 dark:border-neutral-700",
33
102
  icon: null,
34
103
  },
35
104
  success: {
@@ -39,7 +108,8 @@ const variantStyles: Record<
39
108
  description: "text-emerald-800 dark:text-emerald-200/90",
40
109
  progress: "bg-emerald-600 dark:bg-emerald-400",
41
110
  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",
111
+ badge:
112
+ "bg-emerald-100 text-emerald-900 dark:bg-emerald-900/80 dark:text-emerald-100 border-emerald-300 dark:border-emerald-700",
43
113
  icon: CheckCircle2,
44
114
  },
45
115
  error: {
@@ -49,7 +119,8 @@ const variantStyles: Record<
49
119
  description: "text-red-800 dark:text-red-200/90",
50
120
  progress: "bg-destructive dark:bg-red-400",
51
121
  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",
122
+ badge:
123
+ "bg-red-100 text-red-900 dark:bg-red-900/80 dark:text-red-100 border-red-300 dark:border-red-700",
53
124
  icon: AlertCircle,
54
125
  },
55
126
  warning: {
@@ -59,7 +130,8 @@ const variantStyles: Record<
59
130
  description: "text-amber-800 dark:text-amber-200/90",
60
131
  progress: "bg-amber-600 dark:bg-amber-400",
61
132
  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",
133
+ badge:
134
+ "bg-amber-100 text-amber-900 dark:bg-amber-900/80 dark:text-amber-100 border-amber-300 dark:border-amber-700",
63
135
  icon: AlertTriangle,
64
136
  },
65
137
  info: {
@@ -69,7 +141,8 @@ const variantStyles: Record<
69
141
  description: "text-sky-800 dark:text-sky-200/90",
70
142
  progress: "bg-primary dark:bg-sky-400",
71
143
  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",
144
+ badge:
145
+ "bg-sky-100 text-sky-900 dark:bg-sky-900/80 dark:text-sky-100 border-sky-300 dark:border-sky-700",
73
146
  icon: Info,
74
147
  },
75
148
  loading: {
@@ -79,11 +152,255 @@ const variantStyles: Record<
79
152
  description: "text-neutral-600 dark:text-neutral-200",
80
153
  progress: "bg-primary dark:bg-sky-400",
81
154
  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",
155
+ badge:
156
+ "bg-neutral-200 text-neutral-800 dark:bg-neutral-800 dark:text-neutral-100 border-neutral-300 dark:border-neutral-700",
83
157
  icon: Loader2,
84
158
  },
85
159
  };
86
160
 
161
+ // =============================================================================
162
+ // STATE STORE & ENGINE
163
+ // =============================================================================
164
+
165
+ const TOAST_LIMIT = 5;
166
+ const DEFAULT_MAX_COUNT = 5;
167
+ const TOAST_REMOVE_DELAY = 0;
168
+
169
+ type Action =
170
+ | { type: "ADD_TOAST"; toast: ToastItem }
171
+ | { type: "UPDATE_TOAST"; toast: Partial<ToastItem> }
172
+ | { type: "DISMISS_TOAST"; toastId?: string }
173
+ | { type: "REMOVE_TOAST"; toastId?: string };
174
+
175
+ interface State {
176
+ toasts: ToastItem[];
177
+ }
178
+
179
+ function genId(): string {
180
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
181
+ return crypto.randomUUID();
182
+ }
183
+ return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
184
+ }
185
+
186
+ const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
187
+
188
+ const addToRemoveQueue = (toastId: string) => {
189
+ if (toastTimeouts.has(toastId)) return;
190
+
191
+ const timeout = setTimeout(() => {
192
+ toastTimeouts.delete(toastId);
193
+ dispatch({ type: "REMOVE_TOAST", toastId });
194
+ }, TOAST_REMOVE_DELAY);
195
+
196
+ toastTimeouts.set(toastId, timeout);
197
+ };
198
+
199
+ export const reducer = (state: State, action: Action): State => {
200
+ switch (action.type) {
201
+ case "ADD_TOAST": {
202
+ const existingByIdIndex = state.toasts.findIndex((t) => t.id === action.toast.id);
203
+
204
+ if (existingByIdIndex !== -1) {
205
+ return {
206
+ ...state,
207
+ toasts: state.toasts.map((t) =>
208
+ t.id === action.toast.id ? { ...t, ...action.toast, open: true } : t
209
+ ),
210
+ };
211
+ }
212
+
213
+ const existingIndex = state.toasts.findIndex(
214
+ (t) =>
215
+ t.open &&
216
+ t.title === action.toast.title &&
217
+ t.description === action.toast.description &&
218
+ (t.variant || "default") === (action.toast.variant || "default")
219
+ );
220
+
221
+ if (existingIndex !== -1) {
222
+ const existing = state.toasts[existingIndex];
223
+ const maxLimit = existing.maxCount ?? action.toast.maxCount ?? DEFAULT_MAX_COUNT;
224
+ const newCount = Math.min(existing.count + 1, maxLimit);
225
+ const maxReached = existing.count + 1 >= maxLimit;
226
+
227
+ const updatedToast: ToastItem = {
228
+ ...existing,
229
+ ...action.toast,
230
+ id: existing.id,
231
+ count: newCount,
232
+ maxReached,
233
+ open: true,
234
+ };
235
+
236
+ const rest = state.toasts.filter((t) => t.id !== existing.id);
237
+ return {
238
+ ...state,
239
+ toasts: [updatedToast, ...rest].slice(0, TOAST_LIMIT),
240
+ };
241
+ }
242
+
243
+ return {
244
+ ...state,
245
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
246
+ };
247
+ }
248
+
249
+ case "UPDATE_TOAST":
250
+ return {
251
+ ...state,
252
+ toasts: state.toasts.map((t) =>
253
+ t.id === action.toast.id ? { ...t, ...action.toast } : t
254
+ ),
255
+ };
256
+
257
+ case "DISMISS_TOAST": {
258
+ const { toastId } = action;
259
+
260
+ if (toastId) {
261
+ addToRemoveQueue(toastId);
262
+ } else {
263
+ state.toasts.forEach((t) => addToRemoveQueue(t.id));
264
+ }
265
+
266
+ return {
267
+ ...state,
268
+ toasts: state.toasts.map((t) =>
269
+ t.id === toastId || toastId === undefined ? { ...t, open: false } : t
270
+ ),
271
+ };
272
+ }
273
+
274
+ case "REMOVE_TOAST":
275
+ if (action.toastId === undefined) return { ...state, toasts: [] };
276
+ return {
277
+ ...state,
278
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
279
+ };
280
+
281
+ default:
282
+ return state;
283
+ }
284
+ };
285
+
286
+ const listeners: Array<(state: State) => void> = [];
287
+ let memoryState: State = { toasts: [] };
288
+
289
+ function dispatch(action: Action) {
290
+ memoryState = reducer(memoryState, action);
291
+ listeners.forEach((listener) => listener(memoryState));
292
+ }
293
+
294
+ // =============================================================================
295
+ // DISPATCHER API & CONVENIENCE METHODS
296
+ // =============================================================================
297
+
298
+ export function toast(props: ToastOptions) {
299
+ const id = props.id || genId();
300
+
301
+ const update = (updatedProps: ToastOptions) =>
302
+ dispatch({ type: "UPDATE_TOAST", toast: { ...updatedProps, id } });
303
+
304
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
305
+
306
+ dispatch({
307
+ type: "ADD_TOAST",
308
+ toast: {
309
+ ...props,
310
+ id,
311
+ open: true,
312
+ count: 1,
313
+ maxReached: false,
314
+ },
315
+ });
316
+
317
+ return { id, dismiss, update };
318
+ }
319
+
320
+ toast.success = (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "variant">) =>
321
+ toast({ ...options, title, variant: "success" });
322
+
323
+ toast.error = (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "variant">) =>
324
+ toast({ ...options, title, variant: "error" });
325
+
326
+ toast.warning = (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "variant">) =>
327
+ toast({ ...options, title, variant: "warning" });
328
+
329
+ toast.info = (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "variant">) =>
330
+ toast({ ...options, title, variant: "info" });
331
+
332
+ toast.loading = (title: React.ReactNode, options?: Omit<ToastOptions, "title" | "variant">) =>
333
+ toast({ ...options, title, variant: "loading", duration: 0 });
334
+
335
+ toast.dismiss = (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId });
336
+
337
+ toast.promise = <T,>(
338
+ promise: Promise<T> | (() => Promise<T>),
339
+ msgs: {
340
+ loading: React.ReactNode;
341
+ success: React.ReactNode | ((data: T) => React.ReactNode);
342
+ error: React.ReactNode | ((err: unknown) => React.ReactNode);
343
+ },
344
+ options?: ToastOptions
345
+ ) => {
346
+ const instance = toast({
347
+ ...options,
348
+ variant: "loading",
349
+ title: msgs.loading,
350
+ duration: 0,
351
+ });
352
+
353
+ const promiseFn = typeof promise === "function" ? promise() : promise;
354
+
355
+ promiseFn
356
+ .then((data) => {
357
+ const successTitle = typeof msgs.success === "function" ? msgs.success(data) : msgs.success;
358
+ toast.success(successTitle, {
359
+ ...options,
360
+ id: instance.id,
361
+ duration: options?.duration ?? 4000,
362
+ });
363
+ })
364
+ .catch((err: unknown) => {
365
+ const errorTitle = typeof msgs.error === "function" ? msgs.error(err) : msgs.error;
366
+ toast.error(errorTitle, {
367
+ ...options,
368
+ id: instance.id,
369
+ duration: options?.duration ?? 5000,
370
+ });
371
+ });
372
+
373
+ return promiseFn;
374
+ };
375
+
376
+ // =============================================================================
377
+ // REACT CONSUMER HOOK
378
+ // =============================================================================
379
+
380
+ export function useToast() {
381
+ const [state, setState] = React.useState<State>(memoryState);
382
+
383
+ React.useEffect(() => {
384
+ listeners.push(setState);
385
+ return () => {
386
+ const index = listeners.indexOf(setState);
387
+ if (index > -1) {
388
+ listeners.splice(index, 1);
389
+ }
390
+ };
391
+ }, []);
392
+
393
+ return {
394
+ ...state,
395
+ toast,
396
+ dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
397
+ };
398
+ }
399
+
400
+ // =============================================================================
401
+ // UI COMPONENTS (TOASTER & TOAST ELEMENT)
402
+ // =============================================================================
403
+
87
404
  export function Toaster({ defaultDuration = 4000, position = "top-center" }: ToasterProps) {
88
405
  const { toasts, dismiss } = useToast();
89
406
 
@@ -100,52 +417,26 @@ export function Toaster({ defaultDuration = 4000, position = "top-center" }: Toa
100
417
  <>
101
418
  <style>{`
102
419
  @keyframes toast-progress {
103
- from {
104
- transform: scaleX(1);
105
- }
106
- to {
107
- transform: scaleX(0);
108
- }
420
+ from { transform: scaleX(1); }
421
+ to { transform: scaleX(0); }
109
422
  }
110
423
  @keyframes toast-expand {
111
- from {
112
- grid-template-rows: 0fr;
113
- }
114
- to {
115
- grid-template-rows: 1fr;
116
- }
424
+ from { grid-template-rows: 0fr; }
425
+ to { grid-template-rows: 1fr; }
117
426
  }
118
427
  @keyframes toast-fade-in {
119
- from {
120
- opacity: 0;
121
- transform: translateY(-4px);
122
- }
123
- to {
124
- opacity: 1;
125
- transform: translateY(0);
126
- }
428
+ from { opacity: 0; transform: translateY(-4px); }
429
+ to { opacity: 1; transform: translateY(0); }
127
430
  }
128
431
  @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
- }
432
+ 0% { transform: scale(0.6); }
433
+ 50% { transform: scale(1.2); }
434
+ 100% { transform: scale(1); }
138
435
  }
139
436
  @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
- }
437
+ 0%, 100% { transform: translateX(0); }
438
+ 25% { transform: translateX(-2px); }
439
+ 75% { transform: translateX(2px); }
149
440
  }
150
441
  `}</style>
151
442
 
@@ -181,12 +472,10 @@ function ToastElement({
181
472
  const startTimeRef = React.useRef<number>(0);
182
473
  const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
183
474
 
184
- // Update remaining ref on prop changes without triggering synchronous setState re-renders
185
475
  React.useEffect(() => {
186
476
  remainingTimeRef.current = activeDuration;
187
477
  }, [toast.variant, toast.title, toast.count, activeDuration]);
188
478
 
189
- // Handle countdown timeout
190
479
  React.useEffect(() => {
191
480
  if (!isAutoDismissible || isPaused) {
192
481
  if (timerRef.current) clearTimeout(timerRef.current);
@@ -227,6 +516,7 @@ function ToastElement({
227
516
 
228
517
  const userHasBg = Boolean(toast.customColor?.bg || toast.className?.match(/(?:^|\s)bg-/));
229
518
  const userHasBorder = Boolean(toast.customColor?.border || toast.className?.match(/(?:^|\s)border-/));
519
+ const userHasText = Boolean(toast.customColor?.text || toast.className?.match(/(?:^|\s)text-/));
230
520
 
231
521
  const isError = variant === "error";
232
522
 
@@ -246,7 +536,7 @@ function ToastElement({
246
536
  ) : (
247
537
  IconComponent && (
248
538
  <IconComponent
249
- className={`w-5 h-5 mt-0.5 shrink-0 ${defaultStyle.iconColor}`}
539
+ className={`w-5 h-5 mt-0.5 shrink-0 ${!toast.customColor?.icon ? defaultStyle.iconColor : ""}`}
250
540
  style={{ color: toast.customColor?.icon }}
251
541
  />
252
542
  )
@@ -256,7 +546,11 @@ function ToastElement({
256
546
  <div className="flex-1 text-sm">
257
547
  <div className="flex items-center gap-2">
258
548
  {toast.title && (
259
- <div className={`font-semibold leading-tight tracking-tight ${defaultStyle.title}`}>
549
+ <div
550
+ className={`font-semibold leading-tight tracking-tight ${
551
+ !userHasText ? defaultStyle.title : ""
552
+ }`}
553
+ >
260
554
  {toast.title}
261
555
  </div>
262
556
  )}
@@ -289,7 +583,9 @@ function ToastElement({
289
583
  >
290
584
  <div className="overflow-hidden">
291
585
  <div
292
- className={`leading-relaxed text-xs pt-1 opacity-0 font-normal ${defaultStyle.description}`}
586
+ className={`leading-relaxed text-xs pt-1 opacity-0 font-normal ${
587
+ !userHasText ? defaultStyle.description : "opacity-90"
588
+ }`}
293
589
  style={{
294
590
  animation: "toast-fade-in 200ms ease-out 250ms forwards",
295
591
  }}
@@ -306,12 +602,12 @@ function ToastElement({
306
602
  <button
307
603
  onClick={onDismiss}
308
604
  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"
605
+ className="p-1 rounded-md opacity-70 hover:opacity-100 hover:bg-neutral-500/15 dark:hover:bg-white/10 transition-colors text-inherit"
310
606
  >
311
607
  <X className="w-4 h-4" />
312
608
  </button>
313
609
 
314
- {/* Progress Bar resets animation on each state change */}
610
+ {/* Progress Bar */}
315
611
  {showProgress && isAutoDismissible && (
316
612
  <div
317
613
  key={`${toast.id}-${toast.variant}-${toast.count}`}