veloria-ui 0.1.2

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/LICENSE +21 -0
  3. package/README.md +253 -0
  4. package/dist/cli/index.js +511 -0
  5. package/dist/index.d.mts +1317 -0
  6. package/dist/index.d.ts +1317 -0
  7. package/dist/index.js +5373 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/index.mjs +5130 -0
  10. package/dist/index.mjs.map +1 -0
  11. package/dist/provider.d.mts +15 -0
  12. package/dist/provider.d.ts +15 -0
  13. package/dist/provider.js +1197 -0
  14. package/dist/provider.js.map +1 -0
  15. package/dist/provider.mjs +1161 -0
  16. package/dist/provider.mjs.map +1 -0
  17. package/dist/tailwind.d.ts +25 -0
  18. package/dist/tailwind.js +129 -0
  19. package/package.json +138 -0
  20. package/src/cli/index.ts +303 -0
  21. package/src/cli/registry.ts +139 -0
  22. package/src/components/advanced-forms/index.tsx +975 -0
  23. package/src/components/basic/Button.tsx +135 -0
  24. package/src/components/basic/IconButton.tsx +69 -0
  25. package/src/components/basic/index.tsx +446 -0
  26. package/src/components/data-display/index.tsx +1158 -0
  27. package/src/components/feedback/index.tsx +1051 -0
  28. package/src/components/forms/index.tsx +476 -0
  29. package/src/components/layout/index.tsx +296 -0
  30. package/src/components/media/index.tsx +437 -0
  31. package/src/components/navigation/index.tsx +484 -0
  32. package/src/components/overlay/index.tsx +473 -0
  33. package/src/components/utility/index.tsx +566 -0
  34. package/src/hooks/index.ts +602 -0
  35. package/src/hooks/use-toast.tsx +74 -0
  36. package/src/index.ts +396 -0
  37. package/src/provider.tsx +54 -0
  38. package/src/styles/atlas.css +252 -0
  39. package/src/tailwind.ts +124 -0
  40. package/src/types/index.ts +95 -0
  41. package/src/utils/cn.ts +66 -0
@@ -0,0 +1,602 @@
1
+ import * as React from "react";
2
+
3
+ // ─── useDisclosure ─────────────────────────────────────────────────────────
4
+
5
+ export interface UseDisclosureOptions {
6
+ defaultOpen?: boolean;
7
+ onOpen?: () => void;
8
+ onClose?: () => void;
9
+ }
10
+
11
+ /**
12
+ * Manages open/close state for modals, drawers, popovers — anything
13
+ * that needs to toggle. Returns stable callbacks so child components
14
+ * don't re-render on every parent render.
15
+ */
16
+ export function useDisclosure(options: UseDisclosureOptions = {}) {
17
+ const [isOpen, setIsOpen] = React.useState(options.defaultOpen ?? false);
18
+
19
+ const open = React.useCallback(() => {
20
+ setIsOpen(true);
21
+ options.onOpen?.();
22
+ }, [options]);
23
+
24
+ const close = React.useCallback(() => {
25
+ setIsOpen(false);
26
+ options.onClose?.();
27
+ }, [options]);
28
+
29
+ const toggle = React.useCallback(() => {
30
+ setIsOpen((prev) => {
31
+ if (prev) options.onClose?.();
32
+ else options.onOpen?.();
33
+ return !prev;
34
+ });
35
+ }, [options]);
36
+
37
+ return { isOpen, open, close, toggle, onOpenChange: setIsOpen };
38
+ }
39
+
40
+ // ─── useMediaQuery ─────────────────────────────────────────────────────────
41
+
42
+ /**
43
+ * Subscribes to a CSS media query and returns whether it currently matches.
44
+ * SSR-safe — returns false on the server.
45
+ */
46
+ export function useMediaQuery(query: string): boolean {
47
+ const [matches, setMatches] = React.useState(false);
48
+
49
+ React.useEffect(() => {
50
+ if (typeof window === "undefined") return;
51
+ const mq = window.matchMedia(query);
52
+ setMatches(mq.matches);
53
+ const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
54
+ mq.addEventListener("change", handler);
55
+ return () => mq.removeEventListener("change", handler);
56
+ }, [query]);
57
+
58
+ return matches;
59
+ }
60
+
61
+ // ─── useBreakpoint ─────────────────────────────────────────────────────────
62
+
63
+ const breakpoints = {
64
+ sm: "(min-width: 640px)",
65
+ md: "(min-width: 768px)",
66
+ lg: "(min-width: 1024px)",
67
+ xl: "(min-width: 1280px)",
68
+ "2xl": "(min-width: 1536px)",
69
+ };
70
+
71
+ /**
72
+ * Returns true when the viewport is at or above the given Tailwind breakpoint.
73
+ *
74
+ * @example
75
+ * const isDesktop = useBreakpoint("lg");
76
+ */
77
+ export function useBreakpoint(bp: keyof typeof breakpoints): boolean {
78
+ return useMediaQuery(breakpoints[bp]);
79
+ }
80
+
81
+ // ─── useClipboard ──────────────────────────────────────────────────────────
82
+
83
+ export interface UseClipboardOptions {
84
+ timeout?: number;
85
+ }
86
+
87
+ /**
88
+ * Copies text to the clipboard and briefly flips `copied` to true.
89
+ * Falls back to execCommand for older browsers (looking at you, Safari).
90
+ *
91
+ * @example
92
+ * const { copy, copied } = useClipboard();
93
+ * <button onClick={() => copy(code)}>{copied ? "Copied!" : "Copy"}</button>
94
+ */
95
+ export function useClipboard(options: UseClipboardOptions = {}) {
96
+ const [copied, setCopied] = React.useState(false);
97
+
98
+ const copy = React.useCallback(async (text: string) => {
99
+ if (typeof navigator === "undefined") return;
100
+ try {
101
+ await navigator.clipboard.writeText(text);
102
+ setCopied(true);
103
+ setTimeout(() => setCopied(false), options.timeout ?? 2000);
104
+ } catch {
105
+ // execCommand fallback — deprecated but still works in some envs
106
+ const el = document.createElement("textarea");
107
+ el.value = text;
108
+ document.body.appendChild(el);
109
+ el.select();
110
+ document.execCommand("copy");
111
+ document.body.removeChild(el);
112
+ setCopied(true);
113
+ setTimeout(() => setCopied(false), options.timeout ?? 2000);
114
+ }
115
+ }, [options.timeout]);
116
+
117
+ return { copy, copied };
118
+ }
119
+
120
+ // ─── useLocalStorage ──────────────────────────────────────────────────────
121
+
122
+ /**
123
+ * useState that persists to localStorage. Reads the initial value
124
+ * from storage on mount and syncs back on every set call.
125
+ * Safe to use with SSR — reads from storage only inside useEffect timing.
126
+ */
127
+ export function useLocalStorage<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] {
128
+ const [value, setValue] = React.useState<T>(() => {
129
+ if (typeof window === "undefined") return defaultValue;
130
+ try {
131
+ const stored = window.localStorage.getItem(key);
132
+ return stored ? (JSON.parse(stored) as T) : defaultValue;
133
+ } catch {
134
+ return defaultValue;
135
+ }
136
+ });
137
+
138
+ const set = React.useCallback((newValue: T | ((prev: T) => T)) => {
139
+ setValue((prev) => {
140
+ const next = typeof newValue === "function" ? (newValue as (p: T) => T)(prev) : newValue;
141
+ try {
142
+ window.localStorage.setItem(key, JSON.stringify(next));
143
+ } catch { /* quota exceeded or private mode — silently ignore */ }
144
+ return next;
145
+ });
146
+ }, [key]);
147
+
148
+ return [value, set];
149
+ }
150
+
151
+ // ─── useTheme ──────────────────────────────────────────────────────────────
152
+
153
+ export type AtlasTheme = "light" | "dark" | "system";
154
+
155
+ /**
156
+ * Read and set the current Veloria UI theme.
157
+ * Persists the selection to localStorage under "atlas-theme".
158
+ * Applies the "dark" class to <html> so Tailwind's dark: utilities kick in.
159
+ *
160
+ * @example
161
+ * const { theme, setTheme } = useTheme();
162
+ * <button onClick={() => setTheme("dark")}>Go dark</button>
163
+ */
164
+ export function useTheme() {
165
+ const [theme, setThemeState] = useLocalStorage<AtlasTheme>("atlas-theme", "system");
166
+
167
+ const resolvedTheme = React.useMemo<"light" | "dark">(() => {
168
+ if (theme === "system") {
169
+ return typeof window !== "undefined" && window.matchMedia("(prefers-color-scheme: dark)").matches
170
+ ? "dark"
171
+ : "light";
172
+ }
173
+ return theme;
174
+ }, [theme]);
175
+
176
+ const setTheme = React.useCallback((t: AtlasTheme) => {
177
+ setThemeState(t);
178
+ if (typeof document !== "undefined") {
179
+ const root = document.documentElement;
180
+ root.classList.remove("light", "dark");
181
+ if (t !== "system") root.classList.add(t);
182
+ }
183
+ }, [setThemeState]);
184
+
185
+ return { theme, resolvedTheme, setTheme };
186
+ }
187
+
188
+ // ─── useDebounce ───────────────────────────────────────────────────────────
189
+
190
+ /**
191
+ * Delays updating the returned value until `delay` ms have passed
192
+ * without the input changing. Classic use case: search inputs.
193
+ */
194
+ export function useDebounce<T>(value: T, delay: number): T {
195
+ const [debouncedValue, setDebouncedValue] = React.useState(value);
196
+
197
+ React.useEffect(() => {
198
+ const timer = setTimeout(() => setDebouncedValue(value), delay);
199
+ return () => clearTimeout(timer);
200
+ }, [value, delay]);
201
+
202
+ return debouncedValue;
203
+ }
204
+
205
+ // ─── useOnClickOutside ────────────────────────────────────────────────────
206
+
207
+ /**
208
+ * Fires the handler when a click happens outside of the ref'd element.
209
+ * Used heavily inside Veloria UI popovers, dropdowns, and comboboxes.
210
+ */
211
+ export function useOnClickOutside<T extends HTMLElement>(
212
+ ref: React.RefObject<T>,
213
+ handler: (event: MouseEvent | TouchEvent) => void
214
+ ) {
215
+ React.useEffect(() => {
216
+ const listener = (event: MouseEvent | TouchEvent) => {
217
+ if (!ref.current || ref.current.contains(event.target as Node)) return;
218
+ handler(event);
219
+ };
220
+ document.addEventListener("mousedown", listener);
221
+ document.addEventListener("touchstart", listener);
222
+ return () => {
223
+ document.removeEventListener("mousedown", listener);
224
+ document.removeEventListener("touchstart", listener);
225
+ };
226
+ }, [ref, handler]);
227
+ }
228
+
229
+ // ─── useKeydown ───────────────────────────────────────────────────────────
230
+
231
+ /**
232
+ * Attaches a keydown listener to window for the given key.
233
+ * Supports modifier checks (Ctrl, Meta, Shift).
234
+ * Pass `enabled: false` to temporarily disable without removing the hook call.
235
+ *
236
+ * @example
237
+ * useKeydown("k", openCommandPalette, { metaKey: true });
238
+ */
239
+ export function useKeydown(
240
+ key: string,
241
+ handler: (event: KeyboardEvent) => void,
242
+ options: { ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean; enabled?: boolean } = {}
243
+ ) {
244
+ React.useEffect(() => {
245
+ if (options.enabled === false) return;
246
+ const listener = (event: KeyboardEvent) => {
247
+ if (event.key !== key) return;
248
+ if (options.ctrlKey && !event.ctrlKey) return;
249
+ if (options.metaKey && !event.metaKey) return;
250
+ if (options.shiftKey && !event.shiftKey) return;
251
+ handler(event);
252
+ };
253
+ window.addEventListener("keydown", listener);
254
+ return () => window.removeEventListener("keydown", listener);
255
+ }, [key, handler, options]);
256
+ }
257
+
258
+ // ─── useMounted ───────────────────────────────────────────────────────────
259
+
260
+ /**
261
+ * Returns true after the component has mounted on the client.
262
+ * Use this to guard any DOM-dependent code in SSR environments
263
+ * (Next.js, Remix, etc.) without suppressHydrationWarning hacks.
264
+ */
265
+ export function useMounted(): boolean {
266
+ const [mounted, setMounted] = React.useState(false);
267
+ React.useEffect(() => setMounted(true), []);
268
+ return mounted;
269
+ }
270
+
271
+ export { useId } from "react";
272
+
273
+ // ═══════════════════════════════════════════════════════════════
274
+ // New in v0.1.2
275
+ // ═══════════════════════════════════════════════════════════════
276
+
277
+
278
+ // ─── useForm ──────────────────────────────────────────────────────────────
279
+
280
+ type FieldValue = string | number | boolean | undefined;
281
+
282
+ export interface UseFormOptions<T extends Record<string, FieldValue>> {
283
+ initialValues: T;
284
+ validate?: (values: T) => Partial<Record<keyof T, string>>;
285
+ onSubmit?: (values: T) => void | Promise<void>;
286
+ }
287
+
288
+ export interface UseFormReturn<T extends Record<string, FieldValue>> {
289
+ values: T;
290
+ errors: Partial<Record<keyof T, string>>;
291
+ touched: Partial<Record<keyof T, boolean>>;
292
+ isSubmitting: boolean;
293
+ isDirty: boolean;
294
+ isValid: boolean;
295
+ setValue: (field: keyof T, value: FieldValue) => void;
296
+ setValues: (values: Partial<T>) => void;
297
+ setError: (field: keyof T, error: string) => void;
298
+ clearError: (field: keyof T) => void;
299
+ handleChange: (field: keyof T) => (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => void;
300
+ handleBlur: (field: keyof T) => () => void;
301
+ handleSubmit: (e?: React.FormEvent) => void;
302
+ reset: () => void;
303
+ }
304
+
305
+ export function useForm<T extends Record<string, FieldValue>>(
306
+ options: UseFormOptions<T>
307
+ ): UseFormReturn<T> {
308
+ const [values, setValuesState] = React.useState<T>(options.initialValues);
309
+ const [errors, setErrors] = React.useState<Partial<Record<keyof T, string>>>({});
310
+ const [touched, setTouched] = React.useState<Partial<Record<keyof T, boolean>>>({});
311
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
312
+
313
+ const isDirty = JSON.stringify(values) !== JSON.stringify(options.initialValues);
314
+
315
+ const runValidation = React.useCallback((vals: T) => {
316
+ if (!options.validate) return {};
317
+ return options.validate(vals);
318
+ }, [options]);
319
+
320
+ const isValid = Object.keys(runValidation(values)).length === 0;
321
+
322
+ const setValue = React.useCallback((field: keyof T, value: FieldValue) => {
323
+ setValuesState((prev) => ({ ...prev, [field]: value }));
324
+ }, []);
325
+
326
+ const setValues = React.useCallback((newValues: Partial<T>) => {
327
+ setValuesState((prev) => ({ ...prev, ...newValues }));
328
+ }, []);
329
+
330
+ const setError = React.useCallback((field: keyof T, error: string) => {
331
+ setErrors((prev) => ({ ...prev, [field]: error }));
332
+ }, []);
333
+
334
+ const clearError = React.useCallback((field: keyof T) => {
335
+ setErrors((prev) => {
336
+ const next = { ...prev };
337
+ delete next[field];
338
+ return next;
339
+ });
340
+ }, []);
341
+
342
+ const handleChange = React.useCallback((field: keyof T) => (
343
+ e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
344
+ ) => {
345
+ const val = e.target.type === "checkbox"
346
+ ? (e.target as HTMLInputElement).checked
347
+ : e.target.value;
348
+ setValue(field, val as FieldValue);
349
+ if (touched[field]) {
350
+ const newVals = { ...values, [field]: val };
351
+ const errs = runValidation(newVals as T);
352
+ setErrors(errs);
353
+ }
354
+ }, [values, touched, setValue, runValidation]);
355
+
356
+ const handleBlur = React.useCallback((field: keyof T) => () => {
357
+ setTouched((prev) => ({ ...prev, [field]: true }));
358
+ const errs = runValidation(values);
359
+ setErrors(errs);
360
+ }, [values, runValidation]);
361
+
362
+ const handleSubmit = React.useCallback((e?: React.FormEvent) => {
363
+ e?.preventDefault();
364
+ const allTouched = Object.keys(values).reduce(
365
+ (acc, k) => ({ ...acc, [k]: true }),
366
+ {} as Partial<Record<keyof T, boolean>>
367
+ );
368
+ setTouched(allTouched);
369
+ const errs = runValidation(values);
370
+ setErrors(errs);
371
+ if (Object.keys(errs).length > 0) return;
372
+ setIsSubmitting(true);
373
+ Promise.resolve(options.onSubmit?.(values)).finally(() => setIsSubmitting(false));
374
+ }, [values, options, runValidation]);
375
+
376
+ const reset = React.useCallback(() => {
377
+ setValuesState(options.initialValues);
378
+ setErrors({});
379
+ setTouched({});
380
+ setIsSubmitting(false);
381
+ }, [options.initialValues]);
382
+
383
+ return {
384
+ values, errors, touched, isSubmitting, isDirty, isValid,
385
+ setValue, setValues, setError, clearError,
386
+ handleChange, handleBlur, handleSubmit, reset,
387
+ };
388
+ }
389
+
390
+ // ─── usePagination ────────────────────────────────────────────────────────
391
+
392
+ export interface UsePaginationOptions {
393
+ total: number;
394
+ pageSize?: number;
395
+ defaultPage?: number;
396
+ onChange?: (page: number, pageSize: number) => void;
397
+ }
398
+
399
+ export interface UsePaginationReturn {
400
+ page: number;
401
+ pageSize: number;
402
+ totalPages: number;
403
+ total: number;
404
+ from: number;
405
+ to: number;
406
+ hasPrev: boolean;
407
+ hasNext: boolean;
408
+ goTo: (page: number) => void;
409
+ goNext: () => void;
410
+ goPrev: () => void;
411
+ goFirst: () => void;
412
+ goLast: () => void;
413
+ setPageSize: (size: number) => void;
414
+ }
415
+
416
+ export function usePagination({
417
+ total,
418
+ pageSize: defaultPageSize = 10,
419
+ defaultPage = 1,
420
+ onChange,
421
+ }: UsePaginationOptions): UsePaginationReturn {
422
+ const [page, setPage] = React.useState(defaultPage);
423
+ const [pageSize, setPageSizeState] = React.useState(defaultPageSize);
424
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
425
+
426
+ const goTo = React.useCallback((p: number) => {
427
+ const clamped = Math.max(1, Math.min(p, totalPages));
428
+ setPage(clamped);
429
+ onChange?.(clamped, pageSize);
430
+ }, [totalPages, pageSize, onChange]);
431
+
432
+ const setPageSize = React.useCallback((size: number) => {
433
+ setPageSizeState(size);
434
+ setPage(1);
435
+ onChange?.(1, size);
436
+ }, [onChange]);
437
+
438
+ const from = Math.min((page - 1) * pageSize + 1, total);
439
+ const to = Math.min(page * pageSize, total);
440
+
441
+ return {
442
+ page, pageSize, totalPages, total, from, to,
443
+ hasPrev: page > 1,
444
+ hasNext: page < totalPages,
445
+ goTo,
446
+ goNext: () => goTo(page + 1),
447
+ goPrev: () => goTo(page - 1),
448
+ goFirst: () => goTo(1),
449
+ goLast: () => goTo(totalPages),
450
+ setPageSize,
451
+ };
452
+ }
453
+
454
+ // ─── useIntersection ──────────────────────────────────────────────────────
455
+
456
+ export interface UseIntersectionOptions extends IntersectionObserverInit {
457
+ once?: boolean;
458
+ }
459
+
460
+ export function useIntersection<T extends HTMLElement>(
461
+ options: UseIntersectionOptions = {}
462
+ ): [React.RefCallback<T>, boolean] {
463
+ const [isIntersecting, setIsIntersecting] = React.useState(false);
464
+ const observerRef = React.useRef<IntersectionObserver | null>(null);
465
+ const { once, ...observerOptions } = options;
466
+
467
+ const ref: React.RefCallback<T> = React.useCallback((node) => {
468
+ if (observerRef.current) observerRef.current.disconnect();
469
+ if (!node) return;
470
+
471
+ observerRef.current = new IntersectionObserver(([entry]) => {
472
+ setIsIntersecting(entry.isIntersecting);
473
+ if (entry.isIntersecting && once) {
474
+ observerRef.current?.disconnect();
475
+ }
476
+ }, observerOptions);
477
+
478
+ observerRef.current.observe(node);
479
+ // eslint-disable-next-line react-hooks/exhaustive-deps
480
+ }, [once, observerOptions.threshold, observerOptions.root, observerOptions.rootMargin]);
481
+
482
+ return [ref, isIntersecting];
483
+ }
484
+
485
+ // ─── useWindowSize ────────────────────────────────────────────────────────
486
+
487
+ export interface WindowSize {
488
+ width: number;
489
+ height: number;
490
+ }
491
+
492
+ export function useWindowSize(): WindowSize {
493
+ const [size, setSize] = React.useState<WindowSize>(() => ({
494
+ width: typeof window !== "undefined" ? window.innerWidth : 0,
495
+ height: typeof window !== "undefined" ? window.innerHeight : 0,
496
+ }));
497
+
498
+ React.useEffect(() => {
499
+ if (typeof window === "undefined") return;
500
+ const handler = () => setSize({ width: window.innerWidth, height: window.innerHeight });
501
+ window.addEventListener("resize", handler);
502
+ return () => window.removeEventListener("resize", handler);
503
+ }, []);
504
+
505
+ return size;
506
+ }
507
+
508
+ // ─── useStep ──────────────────────────────────────────────────────────────
509
+
510
+ export interface UseStepOptions {
511
+ total: number;
512
+ defaultStep?: number;
513
+ onChange?: (step: number) => void;
514
+ }
515
+
516
+ export interface UseStepReturn {
517
+ step: number;
518
+ total: number;
519
+ isFirst: boolean;
520
+ isLast: boolean;
521
+ progress: number;
522
+ goNext: () => void;
523
+ goPrev: () => void;
524
+ goTo: (step: number) => void;
525
+ reset: () => void;
526
+ }
527
+
528
+ export function useStep({ total, defaultStep = 0, onChange }: UseStepOptions): UseStepReturn {
529
+ const [step, setStep] = React.useState(defaultStep);
530
+
531
+ const goTo = React.useCallback((s: number) => {
532
+ const clamped = Math.max(0, Math.min(s, total - 1));
533
+ setStep(clamped);
534
+ onChange?.(clamped);
535
+ }, [total, onChange]);
536
+
537
+ return {
538
+ step,
539
+ total,
540
+ isFirst: step === 0,
541
+ isLast: step === total - 1,
542
+ progress: total > 1 ? (step / (total - 1)) * 100 : 0,
543
+ goNext: () => goTo(step + 1),
544
+ goPrev: () => goTo(step - 1),
545
+ goTo,
546
+ reset: () => goTo(defaultStep),
547
+ };
548
+ }
549
+
550
+ // ─── useCountdown ─────────────────────────────────────────────────────────
551
+
552
+ export interface UseCountdownOptions {
553
+ from: number;
554
+ interval?: number;
555
+ onComplete?: () => void;
556
+ autoStart?: boolean;
557
+ }
558
+
559
+ export interface UseCountdownReturn {
560
+ count: number;
561
+ isRunning: boolean;
562
+ start: () => void;
563
+ pause: () => void;
564
+ reset: () => void;
565
+ }
566
+
567
+ export function useCountdown({
568
+ from,
569
+ interval = 1000,
570
+ onComplete,
571
+ autoStart = false,
572
+ }: UseCountdownOptions): UseCountdownReturn {
573
+ const [count, setCount] = React.useState(from);
574
+ const [isRunning, setIsRunning] = React.useState(autoStart);
575
+ const intervalRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
576
+
577
+ React.useEffect(() => {
578
+ if (!isRunning) return;
579
+ intervalRef.current = setInterval(() => {
580
+ setCount((prev) => {
581
+ if (prev <= 1) {
582
+ setIsRunning(false);
583
+ onComplete?.();
584
+ return 0;
585
+ }
586
+ return prev - 1;
587
+ });
588
+ }, interval);
589
+ return () => {
590
+ if (intervalRef.current) clearInterval(intervalRef.current);
591
+ };
592
+ }, [isRunning, interval, onComplete]);
593
+
594
+ return {
595
+ count,
596
+ isRunning,
597
+ start: () => { if (count > 0) setIsRunning(true); },
598
+ pause: () => setIsRunning(false),
599
+ reset: () => { setIsRunning(false); setCount(from); },
600
+ };
601
+ }
602
+
@@ -0,0 +1,74 @@
1
+ /**
2
+ * useToast — fire toasts from anywhere without prop drilling.
3
+ *
4
+ * Pair this with <AtlasProvider> at the root of your app.
5
+ * The context lives there and this hook just taps into it.
6
+ *
7
+ * @example
8
+ * const { toast } = useToast();
9
+ * toast({ title: "Saved!", variant: "success" });
10
+ * toast({ title: "Uh oh", description: "Something broke.", variant: "danger" });
11
+ *
12
+ * — Veloria UI, https://veloria-ui.vercel.app/
13
+ */
14
+
15
+ "use client";
16
+
17
+ import * as React from "react";
18
+
19
+ export type ToastVariant = "default" | "success" | "warning" | "danger" | "info";
20
+
21
+ export interface ToastData {
22
+ id: string;
23
+ title?: React.ReactNode;
24
+ description?: React.ReactNode;
25
+ variant?: ToastVariant;
26
+ duration?: number;
27
+ action?: React.ReactNode;
28
+ }
29
+
30
+ export type ToastInput = Omit<ToastData, "id">;
31
+
32
+ interface ToastContextValue {
33
+ toasts: ToastData[];
34
+ toast: (input: ToastInput) => string;
35
+ dismiss: (id: string) => void;
36
+ dismissAll: () => void;
37
+ }
38
+
39
+ const ToastContext = React.createContext<ToastContextValue | null>(null);
40
+
41
+ export function ToastContextProvider({ children }: { children: React.ReactNode }) {
42
+ const [toasts, setToasts] = React.useState<ToastData[]>([]);
43
+
44
+ const toast = React.useCallback((input: ToastInput): string => {
45
+ const id = Math.random().toString(36).slice(2);
46
+ setToasts((prev) => [...prev, { ...input, id }]);
47
+ return id;
48
+ }, []);
49
+
50
+ const dismiss = React.useCallback((id: string) => {
51
+ setToasts((prev) => prev.filter((t) => t.id !== id));
52
+ }, []);
53
+
54
+ const dismissAll = React.useCallback(() => {
55
+ setToasts([]);
56
+ }, []);
57
+
58
+ return (
59
+ <ToastContext.Provider value={{ toasts, toast, dismiss, dismissAll }}>
60
+ {children}
61
+ </ToastContext.Provider>
62
+ );
63
+ }
64
+
65
+ export function useToast(): ToastContextValue {
66
+ const ctx = React.useContext(ToastContext);
67
+ if (!ctx) {
68
+ throw new Error(
69
+ "useToast must be called inside <AtlasProvider>. " +
70
+ "Make sure you've wrapped your app root — see https://veloria-ui.vercel.app/docs/provider"
71
+ );
72
+ }
73
+ return ctx;
74
+ }