atlasui-lib 0.1.0

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 +157 -0
  2. package/LICENSE +21 -0
  3. package/README.md +253 -0
  4. package/dist/cli/index.js +364 -0
  5. package/dist/index.d.mts +1027 -0
  6. package/dist/index.d.ts +1027 -0
  7. package/dist/index.js +3954 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/index.mjs +3733 -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 +816 -0
  14. package/dist/provider.js.map +1 -0
  15. package/dist/provider.mjs +780 -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 +301 -0
  21. package/src/cli/registry.ts +139 -0
  22. package/src/components/advanced-forms/index.tsx +567 -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 +608 -0
  27. package/src/components/feedback/index.tsx +554 -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 +411 -0
  34. package/src/hooks/index.ts +271 -0
  35. package/src/hooks/use-toast.tsx +74 -0
  36. package/src/index.ts +353 -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,411 @@
1
+ import * as React from "react";
2
+ import { cn } from "../../utils/cn";
3
+
4
+ // ─── ThemeSwitcher ─────────────────────────────────────────────────────────
5
+
6
+ export type Theme = "light" | "dark" | "system";
7
+
8
+ export interface ThemeSwitcherProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color" | "onChange"> {
9
+ value?: Theme;
10
+ onChange?: (theme: Theme) => void;
11
+ variant?: "icon" | "toggle" | "select";
12
+ }
13
+
14
+ const ThemeSwitcher = React.forwardRef<HTMLDivElement, ThemeSwitcherProps>(
15
+ ({ className, value = "system", onChange, variant = "icon", ...props }, ref) => {
16
+ const icons: Record<Theme, React.ReactNode> = {
17
+ light: (
18
+ <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
19
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707M17.657 17.657l-.707-.707M6.343 6.343l-.707-.707M12 8a4 4 0 100 8 4 4 0 000-8z" />
20
+ </svg>
21
+ ),
22
+ dark: (
23
+ <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
24
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
25
+ </svg>
26
+ ),
27
+ system: (
28
+ <svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
29
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
30
+ </svg>
31
+ ),
32
+ };
33
+
34
+ const themes: Theme[] = ["light", "dark", "system"];
35
+
36
+ if (variant === "icon") {
37
+ const next: Record<Theme, Theme> = { light: "dark", dark: "system", system: "light" };
38
+ return (
39
+ <div ref={ref} className={cn("atlas-theme-switcher", className)} {...props}>
40
+ <button
41
+ type="button"
42
+ onClick={() => onChange?.(next[value])}
43
+ aria-label={`Current theme: ${value}. Switch to ${next[value]}`}
44
+ className="h-9 w-9 flex items-center justify-center rounded-md border border-border hover:bg-accent transition-colors"
45
+ >
46
+ {icons[value]}
47
+ </button>
48
+ </div>
49
+ );
50
+ }
51
+
52
+ if (variant === "toggle") {
53
+ return (
54
+ <div ref={ref} className={cn("atlas-theme-switcher inline-flex rounded-md border border-border overflow-hidden", className)} {...props} role="group" aria-label="Theme selection">
55
+ {themes.map((theme) => (
56
+ <button
57
+ key={theme}
58
+ type="button"
59
+ onClick={() => onChange?.(theme)}
60
+ aria-pressed={value === theme}
61
+ aria-label={`${theme} theme`}
62
+ className={cn(
63
+ "flex h-8 w-8 items-center justify-center border-r last:border-r-0 border-border transition-colors",
64
+ value === theme ? "bg-accent text-accent-foreground" : "hover:bg-muted"
65
+ )}
66
+ >
67
+ {icons[theme]}
68
+ </button>
69
+ ))}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ return (
75
+ <div ref={ref} className={cn("atlas-theme-switcher", className)} {...props}>
76
+ <select
77
+ value={value}
78
+ onChange={(e) => onChange?.(e.target.value as Theme)}
79
+ aria-label="Select theme"
80
+ className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
81
+ >
82
+ {themes.map((theme) => (
83
+ <option key={theme} value={theme} className="capitalize">
84
+ {theme.charAt(0).toUpperCase() + theme.slice(1)}
85
+ </option>
86
+ ))}
87
+ </select>
88
+ </div>
89
+ );
90
+ }
91
+ );
92
+ ThemeSwitcher.displayName = "ThemeSwitcher";
93
+
94
+ // ─── CopyButton ────────────────────────────────────────────────────────────
95
+
96
+ export interface CopyButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
97
+ text: string;
98
+ timeout?: number;
99
+ onCopied?: () => void;
100
+ size?: "sm" | "md" | "lg";
101
+ variant?: "icon" | "button";
102
+ label?: string;
103
+ }
104
+
105
+ const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(
106
+ ({ className, text, timeout = 2000, onCopied, size = "md", variant = "icon", label = "Copy", ...props }, ref) => {
107
+ const [copied, setCopied] = React.useState(false);
108
+
109
+ const handleCopy = async () => {
110
+ try {
111
+ await navigator.clipboard.writeText(text);
112
+ setCopied(true);
113
+ onCopied?.();
114
+ setTimeout(() => setCopied(false), timeout);
115
+ } catch {
116
+ // Fallback
117
+ const el = document.createElement("textarea");
118
+ el.value = text;
119
+ document.body.appendChild(el);
120
+ el.select();
121
+ document.execCommand("copy");
122
+ document.body.removeChild(el);
123
+ setCopied(true);
124
+ setTimeout(() => setCopied(false), timeout);
125
+ }
126
+ };
127
+
128
+ const iconSize = size === "sm" ? "h-3.5 w-3.5" : size === "lg" ? "h-5 w-5" : "h-4 w-4";
129
+
130
+ return (
131
+ <button
132
+ ref={ref}
133
+ type="button"
134
+ onClick={handleCopy}
135
+ aria-label={copied ? "Copied!" : label}
136
+ className={cn(
137
+ "atlas-copy-button inline-flex items-center justify-center gap-1.5 rounded-md transition-colors",
138
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
139
+ "disabled:pointer-events-none disabled:opacity-50",
140
+ variant === "icon" && [
141
+ size === "sm" && "h-7 w-7",
142
+ size === "md" && "h-8 w-8",
143
+ size === "lg" && "h-9 w-9",
144
+ "border border-border hover:bg-accent text-muted-foreground hover:text-foreground",
145
+ ],
146
+ variant === "button" && [
147
+ "px-3 border border-border hover:bg-accent text-sm",
148
+ size === "sm" && "h-7 text-xs",
149
+ size === "md" && "h-8",
150
+ size === "lg" && "h-9",
151
+ ],
152
+ copied && "text-success border-success/30",
153
+ className
154
+ )}
155
+ {...props}
156
+ >
157
+ {copied ? (
158
+ <svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
159
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
160
+ </svg>
161
+ ) : (
162
+ <svg className={iconSize} fill="none" stroke="currentColor" viewBox="0 0 24 24">
163
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
164
+ d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
165
+ />
166
+ </svg>
167
+ )}
168
+ {variant === "button" && <span>{copied ? "Copied!" : label}</span>}
169
+ </button>
170
+ );
171
+ }
172
+ );
173
+ CopyButton.displayName = "CopyButton";
174
+
175
+ // ─── KeyboardShortcut ──────────────────────────────────────────────────────
176
+
177
+ export interface KeyboardShortcutProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "color" | "size"> {
178
+ keys: string[];
179
+ separator?: string;
180
+ size?: "sm" | "md" | "lg";
181
+ }
182
+
183
+ const KeyboardShortcut = React.forwardRef<HTMLSpanElement, KeyboardShortcutProps>(
184
+ ({ className, keys, separator = "+", size = "md", ...props }, ref) => (
185
+ <span
186
+ ref={ref}
187
+ className={cn("atlas-kbd inline-flex items-center gap-0.5", className)}
188
+ aria-label={`Keyboard shortcut: ${keys.join(separator)}`}
189
+ {...props}
190
+ >
191
+ {keys.map((key, i) => (
192
+ <React.Fragment key={i}>
193
+ {i > 0 && (
194
+ <span className="text-muted-foreground/60 mx-0.5 text-xs">{separator}</span>
195
+ )}
196
+ <kbd
197
+ className={cn(
198
+ "inline-flex items-center justify-center rounded border border-border bg-muted font-mono font-medium",
199
+ "shadow-[inset_0_-1px_0_0_rgb(0_0_0_/_0.1)] dark:shadow-[inset_0_-1px_0_0_rgb(255_255_255_/_0.05)]",
200
+ size === "sm" && "h-5 min-w-[1.25rem] px-1 text-[10px]",
201
+ size === "md" && "h-6 min-w-[1.5rem] px-1.5 text-xs",
202
+ size === "lg" && "h-7 min-w-[1.75rem] px-2 text-sm",
203
+ )}
204
+ >
205
+ {key}
206
+ </kbd>
207
+ </React.Fragment>
208
+ ))}
209
+ </span>
210
+ )
211
+ );
212
+ KeyboardShortcut.displayName = "KeyboardShortcut";
213
+
214
+ // ─── ResizablePanel ────────────────────────────────────────────────────────
215
+
216
+ export interface ResizablePanelProps extends React.HTMLAttributes<HTMLDivElement> {
217
+ defaultSize?: number;
218
+ minSize?: number;
219
+ maxSize?: number;
220
+ direction?: "horizontal" | "vertical";
221
+ onResize?: (size: number) => void;
222
+ }
223
+
224
+ const ResizablePanel = React.forwardRef<HTMLDivElement, ResizablePanelProps>(
225
+ ({
226
+ className,
227
+ children,
228
+ defaultSize = 300,
229
+ minSize = 100,
230
+ maxSize = 800,
231
+ direction = "horizontal",
232
+ onResize,
233
+ style,
234
+ ...props
235
+ }, ref) => {
236
+ const [size, setSize] = React.useState(defaultSize);
237
+ const isDragging = React.useRef(false);
238
+ const startPos = React.useRef(0);
239
+ const startSize = React.useRef(defaultSize);
240
+
241
+ const handleMouseDown = (e: React.MouseEvent) => {
242
+ e.preventDefault();
243
+ isDragging.current = true;
244
+ startPos.current = direction === "horizontal" ? e.clientX : e.clientY;
245
+ startSize.current = size;
246
+
247
+ const handleMouseMove = (e: MouseEvent) => {
248
+ if (!isDragging.current) return;
249
+ const pos = direction === "horizontal" ? e.clientX : e.clientY;
250
+ const delta = pos - startPos.current;
251
+ const newSize = Math.min(maxSize, Math.max(minSize, startSize.current + delta));
252
+ setSize(newSize);
253
+ onResize?.(newSize);
254
+ };
255
+
256
+ const handleMouseUp = () => {
257
+ isDragging.current = false;
258
+ window.removeEventListener("mousemove", handleMouseMove);
259
+ window.removeEventListener("mouseup", handleMouseUp);
260
+ };
261
+
262
+ window.addEventListener("mousemove", handleMouseMove);
263
+ window.addEventListener("mouseup", handleMouseUp);
264
+ };
265
+
266
+ return (
267
+ <div
268
+ ref={ref}
269
+ className={cn("atlas-resizable-panel relative overflow-hidden", className)}
270
+ style={{
271
+ ...(direction === "horizontal" ? { width: size } : { height: size }),
272
+ ...style,
273
+ }}
274
+ {...props}
275
+ >
276
+ {children}
277
+ <div
278
+ onMouseDown={handleMouseDown}
279
+ role="separator"
280
+ aria-orientation={direction}
281
+ aria-label="Resize panel"
282
+ tabIndex={0}
283
+ className={cn(
284
+ "atlas-resize-handle absolute z-10 flex items-center justify-center",
285
+ "bg-transparent hover:bg-primary/20 transition-colors cursor-col-resize group",
286
+ direction === "horizontal"
287
+ ? "right-0 top-0 h-full w-1.5 cursor-col-resize"
288
+ : "bottom-0 left-0 w-full h-1.5 cursor-row-resize"
289
+ )}
290
+ >
291
+ <div className={cn(
292
+ "rounded-full bg-border group-hover:bg-primary/50 transition-colors",
293
+ direction === "horizontal" ? "h-8 w-1" : "w-8 h-1"
294
+ )} />
295
+ </div>
296
+ </div>
297
+ );
298
+ }
299
+ );
300
+ ResizablePanel.displayName = "ResizablePanel";
301
+
302
+ // ─── DragDropArea ──────────────────────────────────────────────────────────
303
+
304
+ export interface DragDropAreaProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onDrop" | "onDragOver"> {
305
+ onDrop?: (items: DataTransfer) => void;
306
+ onDragOver?: (e: React.DragEvent) => void;
307
+ accept?: string[];
308
+ disabled?: boolean;
309
+ active?: boolean;
310
+ label?: React.ReactNode;
311
+ icon?: React.ReactNode;
312
+ hint?: React.ReactNode;
313
+ }
314
+
315
+ const DragDropArea = React.forwardRef<HTMLDivElement, DragDropAreaProps>(
316
+ ({
317
+ className,
318
+ onDrop,
319
+ accept,
320
+ disabled,
321
+ active: externalActive,
322
+ label,
323
+ icon,
324
+ hint,
325
+ children,
326
+ ...props
327
+ }, ref) => {
328
+ const [internalActive, setInternalActive] = React.useState(false);
329
+ const active = externalActive ?? internalActive;
330
+ const [dragCounter, setDragCounter] = React.useState(0);
331
+
332
+ const handleDragEnter = (e: React.DragEvent) => {
333
+ e.preventDefault();
334
+ setDragCounter((c) => c + 1);
335
+ setInternalActive(true);
336
+ };
337
+
338
+ const handleDragLeave = () => {
339
+ setDragCounter((c) => {
340
+ const next = c - 1;
341
+ if (next <= 0) setInternalActive(false);
342
+ return next;
343
+ });
344
+ };
345
+
346
+ const handleDragOver = (e: React.DragEvent) => {
347
+ e.preventDefault();
348
+ };
349
+
350
+ const handleDrop = (e: React.DragEvent) => {
351
+ e.preventDefault();
352
+ setDragCounter(0);
353
+ setInternalActive(false);
354
+ if (!disabled) {
355
+ onDrop?.(e.dataTransfer);
356
+ }
357
+ };
358
+
359
+ return (
360
+ <div
361
+ ref={ref}
362
+ onDragEnter={handleDragEnter}
363
+ onDragLeave={handleDragLeave}
364
+ onDragOver={handleDragOver}
365
+ onDrop={handleDrop}
366
+ aria-label="Drop zone"
367
+ aria-disabled={disabled}
368
+ className={cn(
369
+ "atlas-drag-drop flex flex-col items-center justify-center gap-3 rounded-xl border-2 border-dashed p-8 text-center",
370
+ "transition-colors duration-150",
371
+ active && !disabled && "border-primary bg-primary/5",
372
+ !active && "border-border hover:border-primary/50 hover:bg-muted/30",
373
+ disabled && "opacity-50 cursor-not-allowed border-border",
374
+ className
375
+ )}
376
+ {...props}
377
+ >
378
+ {children ?? (
379
+ <>
380
+ <div className={cn(
381
+ "rounded-full p-3 transition-colors",
382
+ active ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground"
383
+ )}>
384
+ {icon ?? (
385
+ <svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
386
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
387
+ d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
388
+ />
389
+ </svg>
390
+ )}
391
+ </div>
392
+ <div>
393
+ <p className="text-sm font-medium">
394
+ {label ?? (active ? "Release to drop" : "Drag & drop files here")}
395
+ </p>
396
+ {hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
397
+ {accept && (
398
+ <p className="mt-1 text-xs text-muted-foreground">
399
+ Accepted: {accept.join(", ")}
400
+ </p>
401
+ )}
402
+ </div>
403
+ </>
404
+ )}
405
+ </div>
406
+ );
407
+ }
408
+ );
409
+ DragDropArea.displayName = "DragDropArea";
410
+
411
+ export { ThemeSwitcher, CopyButton, KeyboardShortcut, ResizablePanel, DragDropArea };
@@ -0,0 +1,271 @@
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 AtlasUI 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 AtlasUI 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";