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.
- package/CHANGELOG.md +157 -0
- package/LICENSE +21 -0
- package/README.md +253 -0
- package/dist/cli/index.js +364 -0
- package/dist/index.d.mts +1027 -0
- package/dist/index.d.ts +1027 -0
- package/dist/index.js +3954 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +3733 -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 +816 -0
- package/dist/provider.js.map +1 -0
- package/dist/provider.mjs +780 -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 +301 -0
- package/src/cli/registry.ts +139 -0
- package/src/components/advanced-forms/index.tsx +567 -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 +608 -0
- package/src/components/feedback/index.tsx +554 -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 +411 -0
- package/src/hooks/index.ts +271 -0
- package/src/hooks/use-toast.tsx +74 -0
- package/src/index.ts +353 -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,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";
|