@marv3l/canopy-ui 1.2.5 → 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.5",
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
 
@@ -223,7 +514,6 @@ function ToastElement({
223
514
  if (toast.customColor?.border) customInlineStyle.borderColor = toast.customColor.border;
224
515
  if (toast.customColor?.text) customInlineStyle.color = toast.customColor.text;
225
516
 
226
- // Check if custom colors or custom tailwind classes exist
227
517
  const userHasBg = Boolean(toast.customColor?.bg || toast.className?.match(/(?:^|\s)bg-/));
228
518
  const userHasBorder = Boolean(toast.customColor?.border || toast.className?.match(/(?:^|\s)border-/));
229
519
  const userHasText = Boolean(toast.customColor?.text || toast.className?.match(/(?:^|\s)text-/));
@@ -317,7 +607,7 @@ function ToastElement({
317
607
  <X className="w-4 h-4" />
318
608
  </button>
319
609
 
320
- {/* Progress Bar resets animation on each state change */}
610
+ {/* Progress Bar */}
321
611
  {showProgress && isAutoDismissible && (
322
612
  <div
323
613
  key={`${toast.id}-${toast.variant}-${toast.count}`}
@@ -333,4 +623,4 @@ function ToastElement({
333
623
  )}
334
624
  </div>
335
625
  );
336
- }
626
+ }