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.
- package/CHANGELOG.md +206 -0
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/dist/cli/index.js +511 -0
- package/dist/index.d.mts +1317 -0
- package/dist/index.d.ts +1317 -0
- package/dist/index.js +5373 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +5130 -0
- package/dist/index.mjs.map +1 -0
- package/dist/provider.d.mts +15 -0
- package/dist/provider.d.ts +15 -0
- package/dist/provider.js +1197 -0
- package/dist/provider.js.map +1 -0
- package/dist/provider.mjs +1161 -0
- package/dist/provider.mjs.map +1 -0
- package/dist/tailwind.d.ts +25 -0
- package/dist/tailwind.js +129 -0
- package/package.json +138 -0
- package/src/cli/index.ts +303 -0
- package/src/cli/registry.ts +139 -0
- package/src/components/advanced-forms/index.tsx +975 -0
- package/src/components/basic/Button.tsx +135 -0
- package/src/components/basic/IconButton.tsx +69 -0
- package/src/components/basic/index.tsx +446 -0
- package/src/components/data-display/index.tsx +1158 -0
- package/src/components/feedback/index.tsx +1051 -0
- package/src/components/forms/index.tsx +476 -0
- package/src/components/layout/index.tsx +296 -0
- package/src/components/media/index.tsx +437 -0
- package/src/components/navigation/index.tsx +484 -0
- package/src/components/overlay/index.tsx +473 -0
- package/src/components/utility/index.tsx +566 -0
- package/src/hooks/index.ts +602 -0
- package/src/hooks/use-toast.tsx +74 -0
- package/src/index.ts +396 -0
- package/src/provider.tsx +54 -0
- package/src/styles/atlas.css +252 -0
- package/src/tailwind.ts +124 -0
- package/src/types/index.ts +95 -0
- 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
|
+
}
|