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,566 @@
|
|
|
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
|
+
|
|
412
|
+
// ═══════════════════════════════════════════════════════════════
|
|
413
|
+
// New in v0.1.2
|
|
414
|
+
// ═══════════════════════════════════════════════════════════════
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
// ─── InfiniteScroll ───────────────────────────────────────────────────────
|
|
418
|
+
|
|
419
|
+
export interface InfiniteScrollProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
420
|
+
onLoadMore: () => void | Promise<void>;
|
|
421
|
+
hasMore: boolean;
|
|
422
|
+
loading?: boolean;
|
|
423
|
+
threshold?: number;
|
|
424
|
+
loader?: React.ReactNode;
|
|
425
|
+
endMessage?: React.ReactNode;
|
|
426
|
+
as?: React.ElementType;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const DefaultLoader = () => (
|
|
430
|
+
<div className="flex justify-center py-4">
|
|
431
|
+
<svg className="h-5 w-5 animate-spin text-muted-foreground" fill="none" viewBox="0 0 24 24">
|
|
432
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
433
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
434
|
+
</svg>
|
|
435
|
+
</div>
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
const InfiniteScroll = React.forwardRef<HTMLDivElement, InfiniteScrollProps>(
|
|
439
|
+
({
|
|
440
|
+
className,
|
|
441
|
+
children,
|
|
442
|
+
onLoadMore,
|
|
443
|
+
hasMore,
|
|
444
|
+
loading,
|
|
445
|
+
threshold = 100,
|
|
446
|
+
loader,
|
|
447
|
+
endMessage,
|
|
448
|
+
as: Comp = "div",
|
|
449
|
+
...props
|
|
450
|
+
}, ref) => {
|
|
451
|
+
const sentinelRef = React.useRef<HTMLDivElement>(null);
|
|
452
|
+
const loadingRef = React.useRef(false);
|
|
453
|
+
|
|
454
|
+
React.useEffect(() => {
|
|
455
|
+
const sentinel = sentinelRef.current;
|
|
456
|
+
if (!sentinel) return;
|
|
457
|
+
|
|
458
|
+
const observer = new IntersectionObserver(
|
|
459
|
+
async (entries) => {
|
|
460
|
+
const entry = entries[0];
|
|
461
|
+
if (entry.isIntersecting && hasMore && !loadingRef.current && !loading) {
|
|
462
|
+
loadingRef.current = true;
|
|
463
|
+
await onLoadMore();
|
|
464
|
+
loadingRef.current = false;
|
|
465
|
+
}
|
|
466
|
+
},
|
|
467
|
+
{ rootMargin: `${threshold}px` }
|
|
468
|
+
);
|
|
469
|
+
|
|
470
|
+
observer.observe(sentinel);
|
|
471
|
+
return () => observer.disconnect();
|
|
472
|
+
}, [hasMore, loading, onLoadMore, threshold]);
|
|
473
|
+
|
|
474
|
+
return (
|
|
475
|
+
<Comp
|
|
476
|
+
ref={ref}
|
|
477
|
+
className={cn("atlas-infinite-scroll", className)}
|
|
478
|
+
{...props}
|
|
479
|
+
>
|
|
480
|
+
{children}
|
|
481
|
+
<div ref={sentinelRef} aria-hidden="true" />
|
|
482
|
+
{loading && (loader ?? <DefaultLoader />)}
|
|
483
|
+
{!hasMore && endMessage && (
|
|
484
|
+
<div className="py-4 text-center text-sm text-muted-foreground">
|
|
485
|
+
{endMessage}
|
|
486
|
+
</div>
|
|
487
|
+
)}
|
|
488
|
+
</Comp>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
);
|
|
492
|
+
InfiniteScroll.displayName = "InfiniteScroll";
|
|
493
|
+
|
|
494
|
+
// ─── VirtualList ──────────────────────────────────────────────────────────
|
|
495
|
+
|
|
496
|
+
export interface VirtualListProps<T> extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
|
|
497
|
+
items: T[];
|
|
498
|
+
itemHeight: number;
|
|
499
|
+
renderItem: (item: T, index: number) => React.ReactNode;
|
|
500
|
+
overscan?: number;
|
|
501
|
+
height?: number | string;
|
|
502
|
+
getItemKey?: (item: T, index: number) => string | number;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function VirtualList<T>({
|
|
506
|
+
className,
|
|
507
|
+
items,
|
|
508
|
+
itemHeight,
|
|
509
|
+
renderItem,
|
|
510
|
+
overscan = 3,
|
|
511
|
+
height = 400,
|
|
512
|
+
getItemKey,
|
|
513
|
+
style,
|
|
514
|
+
...props
|
|
515
|
+
}: VirtualListProps<T>) {
|
|
516
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
517
|
+
const [scrollTop, setScrollTop] = React.useState(0);
|
|
518
|
+
|
|
519
|
+
const containerHeight = typeof height === "number" ? height : 400;
|
|
520
|
+
const totalHeight = items.length * itemHeight;
|
|
521
|
+
|
|
522
|
+
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
|
|
523
|
+
const visibleCount = Math.ceil(containerHeight / itemHeight) + overscan * 2;
|
|
524
|
+
const endIndex = Math.min(items.length - 1, startIndex + visibleCount);
|
|
525
|
+
|
|
526
|
+
const visibleItems = items.slice(startIndex, endIndex + 1);
|
|
527
|
+
|
|
528
|
+
return (
|
|
529
|
+
<div
|
|
530
|
+
ref={containerRef}
|
|
531
|
+
role="list"
|
|
532
|
+
className={cn("atlas-virtual-list overflow-y-auto relative", className)}
|
|
533
|
+
style={{ height, ...style }}
|
|
534
|
+
onScroll={(e) => setScrollTop((e.target as HTMLDivElement).scrollTop)}
|
|
535
|
+
{...props}
|
|
536
|
+
>
|
|
537
|
+
<div style={{ height: totalHeight, position: "relative" }}>
|
|
538
|
+
{visibleItems.map((item, i) => {
|
|
539
|
+
const actualIndex = startIndex + i;
|
|
540
|
+
return (
|
|
541
|
+
<div
|
|
542
|
+
key={getItemKey ? getItemKey(item, actualIndex) : actualIndex}
|
|
543
|
+
role="listitem"
|
|
544
|
+
style={{
|
|
545
|
+
position: "absolute",
|
|
546
|
+
top: actualIndex * itemHeight,
|
|
547
|
+
left: 0,
|
|
548
|
+
right: 0,
|
|
549
|
+
height: itemHeight,
|
|
550
|
+
}}
|
|
551
|
+
>
|
|
552
|
+
{renderItem(item, actualIndex)}
|
|
553
|
+
</div>
|
|
554
|
+
);
|
|
555
|
+
})}
|
|
556
|
+
</div>
|
|
557
|
+
</div>
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
VirtualList.displayName = "VirtualList";
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
export {
|
|
564
|
+
ThemeSwitcher, CopyButton, KeyboardShortcut, ResizablePanel, DragDropArea ,
|
|
565
|
+
InfiniteScroll, VirtualList
|
|
566
|
+
};
|