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,1051 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import * as ToastPrimitive from "@radix-ui/react-toast";
|
|
3
|
+
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
|
4
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
5
|
+
import { cn } from "../../utils/cn";
|
|
6
|
+
|
|
7
|
+
// ─── Alert ─────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
const alertVariants = cva(
|
|
10
|
+
"atlas-alert relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:pl-7",
|
|
11
|
+
{
|
|
12
|
+
variants: {
|
|
13
|
+
variant: {
|
|
14
|
+
default: "bg-background text-foreground",
|
|
15
|
+
info: "border-info/30 bg-info/10 text-info-foreground [&>svg]:text-info",
|
|
16
|
+
success: "border-success/30 bg-success/10 text-success-foreground [&>svg]:text-success",
|
|
17
|
+
warning: "border-warning/30 bg-warning/10 text-warning-foreground [&>svg]:text-warning",
|
|
18
|
+
danger: "border-destructive/30 bg-destructive/10 text-destructive [&>svg]:text-destructive",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
defaultVariants: { variant: "default" },
|
|
22
|
+
}
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
export interface AlertProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof alertVariants> {
|
|
26
|
+
icon?: React.ReactNode;
|
|
27
|
+
closable?: boolean;
|
|
28
|
+
onClose?: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const Alert = React.forwardRef<HTMLDivElement, AlertProps>(
|
|
32
|
+
({ className, variant, icon, closable, onClose, children, ...props }, ref) => (
|
|
33
|
+
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props}>
|
|
34
|
+
{icon}
|
|
35
|
+
<div className="flex items-start justify-between gap-2">
|
|
36
|
+
<div className="flex-1">{children}</div>
|
|
37
|
+
{closable && (
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
onClick={onClose}
|
|
41
|
+
className="shrink-0 rounded-md p-0.5 text-current/50 hover:text-current transition-colors"
|
|
42
|
+
aria-label="Dismiss alert"
|
|
43
|
+
>
|
|
44
|
+
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
45
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
46
|
+
</svg>
|
|
47
|
+
</button>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
)
|
|
52
|
+
);
|
|
53
|
+
Alert.displayName = "Alert";
|
|
54
|
+
|
|
55
|
+
const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
|
56
|
+
({ className, ...props }, ref) => (
|
|
57
|
+
<h5 ref={ref} className={cn("mb-1 font-semibold leading-tight tracking-tight", className)} {...props} />
|
|
58
|
+
)
|
|
59
|
+
);
|
|
60
|
+
AlertTitle.displayName = "AlertTitle";
|
|
61
|
+
|
|
62
|
+
const AlertDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
|
63
|
+
({ className, ...props }, ref) => (
|
|
64
|
+
<p ref={ref} className={cn("text-sm leading-relaxed", className)} {...props} />
|
|
65
|
+
)
|
|
66
|
+
);
|
|
67
|
+
AlertDescription.displayName = "AlertDescription";
|
|
68
|
+
|
|
69
|
+
// ─── Toast ─────────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
const ToastProvider = ToastPrimitive.Provider;
|
|
72
|
+
|
|
73
|
+
const ToastViewport = React.forwardRef<
|
|
74
|
+
React.ElementRef<typeof ToastPrimitive.Viewport>,
|
|
75
|
+
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Viewport>
|
|
76
|
+
>(({ className, ...props }, ref) => (
|
|
77
|
+
<ToastPrimitive.Viewport
|
|
78
|
+
ref={ref}
|
|
79
|
+
className={cn(
|
|
80
|
+
"atlas-toast-viewport fixed bottom-4 right-4 z-[100] flex flex-col gap-2 w-full max-w-sm p-4",
|
|
81
|
+
className
|
|
82
|
+
)}
|
|
83
|
+
{...props}
|
|
84
|
+
/>
|
|
85
|
+
));
|
|
86
|
+
ToastViewport.displayName = ToastPrimitive.Viewport.displayName;
|
|
87
|
+
|
|
88
|
+
const toastVariants = cva(
|
|
89
|
+
[
|
|
90
|
+
"group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden",
|
|
91
|
+
"rounded-lg border p-4 shadow-lg transition-all",
|
|
92
|
+
"data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)]",
|
|
93
|
+
"data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none",
|
|
94
|
+
"data-[state=open]:animate-in data-[state=open]:slide-in-from-bottom-full data-[state=open]:fade-in",
|
|
95
|
+
"data-[state=closed]:animate-out data-[state=closed]:fade-out data-[state=closed]:slide-out-to-right-full",
|
|
96
|
+
],
|
|
97
|
+
{
|
|
98
|
+
variants: {
|
|
99
|
+
variant: {
|
|
100
|
+
default: "bg-background border-border",
|
|
101
|
+
success: "bg-success/10 border-success/20 text-success",
|
|
102
|
+
warning: "bg-warning/10 border-warning/20 text-warning",
|
|
103
|
+
danger: "bg-destructive/10 border-destructive/20 text-destructive",
|
|
104
|
+
info: "bg-info/10 border-info/20 text-info",
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
defaultVariants: { variant: "default" },
|
|
108
|
+
}
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const Toast = React.forwardRef<
|
|
112
|
+
React.ElementRef<typeof ToastPrimitive.Root>,
|
|
113
|
+
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Root> & VariantProps<typeof toastVariants>
|
|
114
|
+
>(({ className, variant, ...props }, ref) => (
|
|
115
|
+
<ToastPrimitive.Root ref={ref} className={cn(toastVariants({ variant }), className)} {...props} />
|
|
116
|
+
));
|
|
117
|
+
Toast.displayName = ToastPrimitive.Root.displayName;
|
|
118
|
+
|
|
119
|
+
const ToastTitle = React.forwardRef<
|
|
120
|
+
React.ElementRef<typeof ToastPrimitive.Title>,
|
|
121
|
+
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Title>
|
|
122
|
+
>(({ className, ...props }, ref) => (
|
|
123
|
+
<ToastPrimitive.Title ref={ref} className={cn("text-sm font-semibold", className)} {...props} />
|
|
124
|
+
));
|
|
125
|
+
ToastTitle.displayName = ToastPrimitive.Title.displayName;
|
|
126
|
+
|
|
127
|
+
const ToastDescription = React.forwardRef<
|
|
128
|
+
React.ElementRef<typeof ToastPrimitive.Description>,
|
|
129
|
+
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Description>
|
|
130
|
+
>(({ className, ...props }, ref) => (
|
|
131
|
+
<ToastPrimitive.Description ref={ref} className={cn("text-sm opacity-80", className)} {...props} />
|
|
132
|
+
));
|
|
133
|
+
ToastDescription.displayName = ToastPrimitive.Description.displayName;
|
|
134
|
+
|
|
135
|
+
const ToastClose = React.forwardRef<
|
|
136
|
+
React.ElementRef<typeof ToastPrimitive.Close>,
|
|
137
|
+
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Close>
|
|
138
|
+
>(({ className, ...props }, ref) => (
|
|
139
|
+
<ToastPrimitive.Close
|
|
140
|
+
ref={ref}
|
|
141
|
+
toast-close=""
|
|
142
|
+
className={cn(
|
|
143
|
+
"ml-auto shrink-0 rounded-md p-0.5 opacity-50 hover:opacity-100 transition-opacity",
|
|
144
|
+
className
|
|
145
|
+
)}
|
|
146
|
+
aria-label="Close"
|
|
147
|
+
{...props}
|
|
148
|
+
>
|
|
149
|
+
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
150
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
151
|
+
</svg>
|
|
152
|
+
</ToastPrimitive.Close>
|
|
153
|
+
));
|
|
154
|
+
ToastClose.displayName = ToastPrimitive.Close.displayName;
|
|
155
|
+
|
|
156
|
+
const ToastAction = React.forwardRef<
|
|
157
|
+
React.ElementRef<typeof ToastPrimitive.Action>,
|
|
158
|
+
React.ComponentPropsWithoutRef<typeof ToastPrimitive.Action>
|
|
159
|
+
>(({ className, ...props }, ref) => (
|
|
160
|
+
<ToastPrimitive.Action
|
|
161
|
+
ref={ref}
|
|
162
|
+
className={cn(
|
|
163
|
+
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium",
|
|
164
|
+
"transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring",
|
|
165
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
166
|
+
className
|
|
167
|
+
)}
|
|
168
|
+
{...props}
|
|
169
|
+
/>
|
|
170
|
+
));
|
|
171
|
+
ToastAction.displayName = ToastPrimitive.Action.displayName;
|
|
172
|
+
|
|
173
|
+
// ─── Snackbar ─────────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
export interface SnackbarProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
|
|
176
|
+
open?: boolean;
|
|
177
|
+
message: React.ReactNode;
|
|
178
|
+
action?: React.ReactNode;
|
|
179
|
+
variant?: "default" | "success" | "warning" | "danger";
|
|
180
|
+
position?: "bottom-center" | "bottom-left" | "bottom-right" | "top-center";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const Snackbar = React.forwardRef<HTMLDivElement, SnackbarProps>(
|
|
184
|
+
({ className, open, message, action, variant = "default", position = "bottom-center", ...props }, ref) => {
|
|
185
|
+
if (!open) return null;
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div
|
|
189
|
+
ref={ref}
|
|
190
|
+
role="status"
|
|
191
|
+
aria-live="polite"
|
|
192
|
+
className={cn(
|
|
193
|
+
"atlas-snackbar fixed z-50 flex items-center gap-4 rounded-lg px-4 py-3 shadow-lg",
|
|
194
|
+
"min-w-[280px] max-w-[480px]",
|
|
195
|
+
position === "bottom-center" && "bottom-4 left-1/2 -translate-x-1/2",
|
|
196
|
+
position === "bottom-left" && "bottom-4 left-4",
|
|
197
|
+
position === "bottom-right" && "bottom-4 right-4",
|
|
198
|
+
position === "top-center" && "top-4 left-1/2 -translate-x-1/2",
|
|
199
|
+
variant === "default" && "bg-foreground text-background",
|
|
200
|
+
variant === "success" && "bg-success text-success-foreground",
|
|
201
|
+
variant === "warning" && "bg-warning text-warning-foreground",
|
|
202
|
+
variant === "danger" && "bg-destructive text-destructive-foreground",
|
|
203
|
+
className
|
|
204
|
+
)}
|
|
205
|
+
{...props}
|
|
206
|
+
>
|
|
207
|
+
<p className="flex-1 text-sm font-medium">{message}</p>
|
|
208
|
+
{action && <div className="shrink-0">{action}</div>}
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
);
|
|
213
|
+
Snackbar.displayName = "Snackbar";
|
|
214
|
+
|
|
215
|
+
// ─── Progress ─────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
export interface ProgressProps extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
|
|
218
|
+
label?: string;
|
|
219
|
+
showValue?: boolean;
|
|
220
|
+
size?: "sm" | "md" | "lg";
|
|
221
|
+
color?: "default" | "success" | "warning" | "danger";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const Progress = React.forwardRef<React.ElementRef<typeof ProgressPrimitive.Root>, ProgressProps>(
|
|
225
|
+
({ className, value, label, showValue, size = "md", color = "default", ...props }, ref) => (
|
|
226
|
+
<div className="atlas-progress w-full">
|
|
227
|
+
{(label || showValue) && (
|
|
228
|
+
<div className="flex justify-between items-center mb-1.5">
|
|
229
|
+
{label && <span className="text-sm font-medium">{label}</span>}
|
|
230
|
+
{showValue && <span className="text-sm text-muted-foreground">{value ?? 0}%</span>}
|
|
231
|
+
</div>
|
|
232
|
+
)}
|
|
233
|
+
<ProgressPrimitive.Root
|
|
234
|
+
ref={ref}
|
|
235
|
+
className={cn(
|
|
236
|
+
"relative overflow-hidden rounded-full bg-secondary",
|
|
237
|
+
size === "sm" && "h-1.5",
|
|
238
|
+
size === "md" && "h-2.5",
|
|
239
|
+
size === "lg" && "h-4",
|
|
240
|
+
className
|
|
241
|
+
)}
|
|
242
|
+
{...props}
|
|
243
|
+
>
|
|
244
|
+
<ProgressPrimitive.Indicator
|
|
245
|
+
className={cn(
|
|
246
|
+
"h-full w-full flex-1 transition-all duration-500 ease-in-out",
|
|
247
|
+
color === "default" && "bg-primary",
|
|
248
|
+
color === "success" && "bg-success",
|
|
249
|
+
color === "warning" && "bg-warning",
|
|
250
|
+
color === "danger" && "bg-destructive",
|
|
251
|
+
)}
|
|
252
|
+
style={{ transform: `translateX(-${100 - (value ?? 0)}%)` }}
|
|
253
|
+
/>
|
|
254
|
+
</ProgressPrimitive.Root>
|
|
255
|
+
</div>
|
|
256
|
+
)
|
|
257
|
+
);
|
|
258
|
+
Progress.displayName = "Progress";
|
|
259
|
+
|
|
260
|
+
// ─── CircularProgress ─────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
export interface CircularProgressProps extends React.SVGAttributes<SVGElement> {
|
|
263
|
+
value?: number;
|
|
264
|
+
size?: number;
|
|
265
|
+
thickness?: number;
|
|
266
|
+
showValue?: boolean;
|
|
267
|
+
label?: string;
|
|
268
|
+
color?: "default" | "success" | "warning" | "danger";
|
|
269
|
+
indeterminate?: boolean;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const CircularProgress = ({
|
|
273
|
+
value = 0,
|
|
274
|
+
size = 48,
|
|
275
|
+
thickness = 4,
|
|
276
|
+
showValue,
|
|
277
|
+
label,
|
|
278
|
+
color = "default",
|
|
279
|
+
indeterminate,
|
|
280
|
+
className,
|
|
281
|
+
...props
|
|
282
|
+
}: CircularProgressProps) => {
|
|
283
|
+
const radius = (size - thickness) / 2;
|
|
284
|
+
const circumference = 2 * Math.PI * radius;
|
|
285
|
+
const offset = circumference - (value / 100) * circumference;
|
|
286
|
+
|
|
287
|
+
const colorMap = {
|
|
288
|
+
default: "stroke-primary",
|
|
289
|
+
success: "stroke-success",
|
|
290
|
+
warning: "stroke-warning",
|
|
291
|
+
danger: "stroke-destructive",
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<div className={cn("atlas-circular-progress relative inline-flex items-center justify-center", className)}>
|
|
296
|
+
<svg
|
|
297
|
+
width={size}
|
|
298
|
+
height={size}
|
|
299
|
+
viewBox={`0 0 ${size} ${size}`}
|
|
300
|
+
fill="none"
|
|
301
|
+
className={indeterminate ? "animate-spin" : ""}
|
|
302
|
+
role="progressbar"
|
|
303
|
+
aria-valuenow={indeterminate ? undefined : value}
|
|
304
|
+
aria-valuemin={0}
|
|
305
|
+
aria-valuemax={100}
|
|
306
|
+
aria-label={label}
|
|
307
|
+
{...props}
|
|
308
|
+
>
|
|
309
|
+
<circle
|
|
310
|
+
cx={size / 2}
|
|
311
|
+
cy={size / 2}
|
|
312
|
+
r={radius}
|
|
313
|
+
strokeWidth={thickness}
|
|
314
|
+
className="stroke-secondary"
|
|
315
|
+
/>
|
|
316
|
+
<circle
|
|
317
|
+
cx={size / 2}
|
|
318
|
+
cy={size / 2}
|
|
319
|
+
r={radius}
|
|
320
|
+
strokeWidth={thickness}
|
|
321
|
+
strokeDasharray={circumference}
|
|
322
|
+
strokeDashoffset={indeterminate ? circumference * 0.75 : offset}
|
|
323
|
+
strokeLinecap="round"
|
|
324
|
+
className={cn("transition-all duration-500", colorMap[color])}
|
|
325
|
+
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
|
326
|
+
/>
|
|
327
|
+
</svg>
|
|
328
|
+
{showValue && !indeterminate && (
|
|
329
|
+
<span className="absolute text-xs font-semibold">{value}%</span>
|
|
330
|
+
)}
|
|
331
|
+
</div>
|
|
332
|
+
);
|
|
333
|
+
};
|
|
334
|
+
CircularProgress.displayName = "CircularProgress";
|
|
335
|
+
|
|
336
|
+
// ─── Skeleton ─────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
export interface SkeletonProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "color"> {
|
|
339
|
+
variant?: "text" | "rect" | "circle";
|
|
340
|
+
width?: string | number;
|
|
341
|
+
height?: string | number;
|
|
342
|
+
lines?: number;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const Skeleton = React.forwardRef<HTMLDivElement, SkeletonProps>(
|
|
346
|
+
({ className, variant = "rect", width, height, lines = 1, style, ...props }, ref) => {
|
|
347
|
+
if (variant === "text" && lines > 1) {
|
|
348
|
+
return (
|
|
349
|
+
<div className={cn("atlas-skeleton space-y-2", className)} ref={ref} {...props}>
|
|
350
|
+
{Array.from({ length: lines }).map((_, i) => (
|
|
351
|
+
<div
|
|
352
|
+
key={i}
|
|
353
|
+
className="h-4 animate-pulse rounded bg-muted"
|
|
354
|
+
style={{ width: i === lines - 1 ? "75%" : "100%" }}
|
|
355
|
+
/>
|
|
356
|
+
))}
|
|
357
|
+
</div>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
<div
|
|
363
|
+
ref={ref}
|
|
364
|
+
className={cn(
|
|
365
|
+
"atlas-skeleton animate-pulse bg-muted",
|
|
366
|
+
variant === "circle" ? "rounded-full" : "rounded-md",
|
|
367
|
+
variant === "text" && "h-4",
|
|
368
|
+
className
|
|
369
|
+
)}
|
|
370
|
+
style={{ width, height, ...style }}
|
|
371
|
+
aria-hidden="true"
|
|
372
|
+
{...props}
|
|
373
|
+
/>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
);
|
|
377
|
+
Skeleton.displayName = "Skeleton";
|
|
378
|
+
|
|
379
|
+
// ─── LoadingSpinner ───────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
export interface LoadingSpinnerProps extends React.SVGAttributes<SVGElement> {
|
|
382
|
+
size?: "xs" | "sm" | "md" | "lg" | "xl";
|
|
383
|
+
label?: string;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const spinnerSizes = { xs: 12, sm: 16, md: 24, lg: 32, xl: 48 };
|
|
387
|
+
|
|
388
|
+
const LoadingSpinner = ({ size = "md", label = "Loading", className, ...props }: LoadingSpinnerProps) => (
|
|
389
|
+
<svg
|
|
390
|
+
width={spinnerSizes[size]}
|
|
391
|
+
height={spinnerSizes[size]}
|
|
392
|
+
viewBox="0 0 24 24"
|
|
393
|
+
fill="none"
|
|
394
|
+
className={cn("atlas-loading-spinner animate-spin text-primary", className)}
|
|
395
|
+
role="status"
|
|
396
|
+
aria-label={label}
|
|
397
|
+
{...props}
|
|
398
|
+
>
|
|
399
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
400
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
401
|
+
</svg>
|
|
402
|
+
);
|
|
403
|
+
LoadingSpinner.displayName = "LoadingSpinner";
|
|
404
|
+
|
|
405
|
+
// ─── EmptyState ───────────────────────────────────────────────────────────
|
|
406
|
+
|
|
407
|
+
export interface EmptyStateProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
|
408
|
+
icon?: React.ReactNode;
|
|
409
|
+
title: string;
|
|
410
|
+
description?: string;
|
|
411
|
+
action?: React.ReactNode;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const EmptyState = React.forwardRef<HTMLDivElement, EmptyStateProps>(
|
|
415
|
+
({ className, icon, title, description, action, ...props }, ref) => (
|
|
416
|
+
<div
|
|
417
|
+
ref={ref}
|
|
418
|
+
className={cn(
|
|
419
|
+
"atlas-empty-state flex flex-col items-center justify-center gap-3 text-center py-16 px-6",
|
|
420
|
+
className
|
|
421
|
+
)}
|
|
422
|
+
{...props}
|
|
423
|
+
>
|
|
424
|
+
{icon && (
|
|
425
|
+
<div className="rounded-full bg-muted p-4 text-muted-foreground [&>svg]:h-8 [&>svg]:w-8">
|
|
426
|
+
{icon}
|
|
427
|
+
</div>
|
|
428
|
+
)}
|
|
429
|
+
<div className="max-w-xs">
|
|
430
|
+
<h3 className="text-base font-semibold">{title}</h3>
|
|
431
|
+
{description && <p className="mt-1 text-sm text-muted-foreground">{description}</p>}
|
|
432
|
+
</div>
|
|
433
|
+
{action && <div className="mt-2">{action}</div>}
|
|
434
|
+
</div>
|
|
435
|
+
)
|
|
436
|
+
);
|
|
437
|
+
EmptyState.displayName = "EmptyState";
|
|
438
|
+
|
|
439
|
+
// ─── StatusIndicator ──────────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
export interface StatusIndicatorProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "color" | "size"> {
|
|
442
|
+
status: "online" | "offline" | "busy" | "away" | "idle";
|
|
443
|
+
label?: string;
|
|
444
|
+
pulse?: boolean;
|
|
445
|
+
size?: "sm" | "md" | "lg";
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const statusColors = {
|
|
449
|
+
online: "bg-success",
|
|
450
|
+
offline: "bg-muted-foreground",
|
|
451
|
+
busy: "bg-destructive",
|
|
452
|
+
away: "bg-warning",
|
|
453
|
+
idle: "bg-warning/60",
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const StatusIndicator = React.forwardRef<HTMLSpanElement, StatusIndicatorProps>(
|
|
457
|
+
({ className, status, label, pulse, size = "md", ...props }, ref) => (
|
|
458
|
+
<span
|
|
459
|
+
ref={ref}
|
|
460
|
+
className={cn("atlas-status-indicator inline-flex items-center gap-1.5", className)}
|
|
461
|
+
{...props}
|
|
462
|
+
>
|
|
463
|
+
<span className="relative inline-flex">
|
|
464
|
+
<span className={cn(
|
|
465
|
+
"rounded-full",
|
|
466
|
+
statusColors[status],
|
|
467
|
+
size === "sm" && "h-1.5 w-1.5",
|
|
468
|
+
size === "md" && "h-2.5 w-2.5",
|
|
469
|
+
size === "lg" && "h-3.5 w-3.5",
|
|
470
|
+
)} />
|
|
471
|
+
{pulse && status === "online" && (
|
|
472
|
+
<span className={cn(
|
|
473
|
+
"absolute inline-flex rounded-full animate-ping opacity-75",
|
|
474
|
+
statusColors[status],
|
|
475
|
+
size === "sm" && "h-1.5 w-1.5",
|
|
476
|
+
size === "md" && "h-2.5 w-2.5",
|
|
477
|
+
size === "lg" && "h-3.5 w-3.5",
|
|
478
|
+
)} />
|
|
479
|
+
)}
|
|
480
|
+
</span>
|
|
481
|
+
{label && <span className="text-sm capitalize">{label ?? status}</span>}
|
|
482
|
+
</span>
|
|
483
|
+
)
|
|
484
|
+
);
|
|
485
|
+
StatusIndicator.displayName = "StatusIndicator";
|
|
486
|
+
|
|
487
|
+
// ─── Notification ─────────────────────────────────────────────────────────
|
|
488
|
+
|
|
489
|
+
export interface NotificationProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
|
490
|
+
title: React.ReactNode;
|
|
491
|
+
description?: React.ReactNode;
|
|
492
|
+
avatar?: React.ReactNode;
|
|
493
|
+
icon?: React.ReactNode;
|
|
494
|
+
time?: React.ReactNode;
|
|
495
|
+
unread?: boolean;
|
|
496
|
+
onClose?: () => void;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const Notification = React.forwardRef<HTMLDivElement, NotificationProps>(
|
|
500
|
+
({ className, title, description, avatar, icon, time, unread, onClose, ...props }, ref) => (
|
|
501
|
+
<div
|
|
502
|
+
ref={ref}
|
|
503
|
+
role="listitem"
|
|
504
|
+
className={cn(
|
|
505
|
+
"atlas-notification flex gap-3 p-4 transition-colors",
|
|
506
|
+
unread && "bg-primary/5",
|
|
507
|
+
className
|
|
508
|
+
)}
|
|
509
|
+
{...props}
|
|
510
|
+
>
|
|
511
|
+
<div className="shrink-0">
|
|
512
|
+
{avatar ?? (
|
|
513
|
+
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-muted text-muted-foreground [&>svg]:h-4 [&>svg]:w-4">
|
|
514
|
+
{icon}
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
517
|
+
</div>
|
|
518
|
+
<div className="flex-1 min-w-0">
|
|
519
|
+
<div className="flex items-start justify-between gap-2">
|
|
520
|
+
<p className={cn("text-sm font-medium leading-snug", unread && "font-semibold")}>{title}</p>
|
|
521
|
+
{unread && <span className="mt-1 h-2 w-2 shrink-0 rounded-full bg-primary" aria-label="Unread" />}
|
|
522
|
+
</div>
|
|
523
|
+
{description && <p className="mt-0.5 text-sm text-muted-foreground line-clamp-2">{description}</p>}
|
|
524
|
+
{time && <p className="mt-1 text-xs text-muted-foreground">{time}</p>}
|
|
525
|
+
</div>
|
|
526
|
+
{onClose && (
|
|
527
|
+
<button
|
|
528
|
+
type="button"
|
|
529
|
+
onClick={onClose}
|
|
530
|
+
className="shrink-0 self-start rounded p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
|
531
|
+
aria-label="Dismiss notification"
|
|
532
|
+
>
|
|
533
|
+
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
534
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
535
|
+
</svg>
|
|
536
|
+
</button>
|
|
537
|
+
)}
|
|
538
|
+
</div>
|
|
539
|
+
)
|
|
540
|
+
);
|
|
541
|
+
Notification.displayName = "Notification";
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
// ═══════════════════════════════════════════════════════════════
|
|
545
|
+
// New in v0.1.2
|
|
546
|
+
// ═══════════════════════════════════════════════════════════════
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
// ─── BannerAlert ──────────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
export interface BannerAlertProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
|
552
|
+
title?: React.ReactNode;
|
|
553
|
+
description?: React.ReactNode;
|
|
554
|
+
variant?: "info" | "success" | "warning" | "danger";
|
|
555
|
+
dismissible?: boolean;
|
|
556
|
+
onDismiss?: () => void;
|
|
557
|
+
action?: React.ReactNode;
|
|
558
|
+
icon?: React.ReactNode;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const bannerVariants = {
|
|
562
|
+
info: "bg-info/10 border-info/30 text-info-foreground [&_.atlas-banner-icon]:text-info",
|
|
563
|
+
success: "bg-success/10 border-success/30 text-success-foreground [&_.atlas-banner-icon]:text-success",
|
|
564
|
+
warning: "bg-warning/10 border-warning/30 text-warning-foreground [&_.atlas-banner-icon]:text-warning",
|
|
565
|
+
danger: "bg-destructive/10 border-destructive/30 text-destructive [&_.atlas-banner-icon]:text-destructive",
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const defaultBannerIcons: Record<string, React.ReactNode> = {
|
|
569
|
+
info: (
|
|
570
|
+
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
571
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
572
|
+
</svg>
|
|
573
|
+
),
|
|
574
|
+
success: (
|
|
575
|
+
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
576
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
577
|
+
</svg>
|
|
578
|
+
),
|
|
579
|
+
warning: (
|
|
580
|
+
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
581
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
582
|
+
</svg>
|
|
583
|
+
),
|
|
584
|
+
danger: (
|
|
585
|
+
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
586
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
587
|
+
</svg>
|
|
588
|
+
),
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
const BannerAlert = React.forwardRef<HTMLDivElement, BannerAlertProps>(
|
|
592
|
+
({ className, title, description, variant = "info", dismissible, onDismiss, action, icon, ...props }, ref) => (
|
|
593
|
+
<div
|
|
594
|
+
ref={ref}
|
|
595
|
+
role="alert"
|
|
596
|
+
className={cn(
|
|
597
|
+
"atlas-banner-alert w-full border-y px-4 py-3",
|
|
598
|
+
bannerVariants[variant],
|
|
599
|
+
className
|
|
600
|
+
)}
|
|
601
|
+
{...props}
|
|
602
|
+
>
|
|
603
|
+
<div className="flex items-start gap-3 max-w-screen-xl mx-auto">
|
|
604
|
+
<span className="atlas-banner-icon shrink-0 mt-0.5">
|
|
605
|
+
{icon ?? defaultBannerIcons[variant]}
|
|
606
|
+
</span>
|
|
607
|
+
<div className="flex-1 min-w-0">
|
|
608
|
+
{title && <p className="font-semibold text-sm">{title}</p>}
|
|
609
|
+
{description && <p className="text-sm mt-0.5 opacity-90">{description}</p>}
|
|
610
|
+
</div>
|
|
611
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
612
|
+
{action}
|
|
613
|
+
{dismissible && (
|
|
614
|
+
<button
|
|
615
|
+
type="button"
|
|
616
|
+
onClick={onDismiss}
|
|
617
|
+
aria-label="Dismiss"
|
|
618
|
+
className="rounded-md p-1 opacity-60 hover:opacity-100 transition-opacity focus:outline-none focus:ring-2 focus:ring-current"
|
|
619
|
+
>
|
|
620
|
+
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
621
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
622
|
+
</svg>
|
|
623
|
+
</button>
|
|
624
|
+
)}
|
|
625
|
+
</div>
|
|
626
|
+
</div>
|
|
627
|
+
</div>
|
|
628
|
+
)
|
|
629
|
+
);
|
|
630
|
+
BannerAlert.displayName = "BannerAlert";
|
|
631
|
+
|
|
632
|
+
// ─── ConfirmDialog ────────────────────────────────────────────────────────
|
|
633
|
+
|
|
634
|
+
export interface ConfirmDialogProps {
|
|
635
|
+
open?: boolean;
|
|
636
|
+
onOpenChange?: (open: boolean) => void;
|
|
637
|
+
title?: React.ReactNode;
|
|
638
|
+
description?: React.ReactNode;
|
|
639
|
+
confirmLabel?: string;
|
|
640
|
+
cancelLabel?: string;
|
|
641
|
+
variant?: "default" | "danger";
|
|
642
|
+
onConfirm?: () => void | Promise<void>;
|
|
643
|
+
onCancel?: () => void;
|
|
644
|
+
loading?: boolean;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const ConfirmDialog = ({
|
|
648
|
+
open,
|
|
649
|
+
onOpenChange,
|
|
650
|
+
title = "Are you sure?",
|
|
651
|
+
description = "This action cannot be undone.",
|
|
652
|
+
confirmLabel = "Confirm",
|
|
653
|
+
cancelLabel = "Cancel",
|
|
654
|
+
variant = "default",
|
|
655
|
+
onConfirm,
|
|
656
|
+
onCancel,
|
|
657
|
+
loading,
|
|
658
|
+
}: ConfirmDialogProps) => {
|
|
659
|
+
const [pending, setPending] = React.useState(false);
|
|
660
|
+
|
|
661
|
+
if (!open) return null;
|
|
662
|
+
|
|
663
|
+
const handleConfirm = async () => {
|
|
664
|
+
setPending(true);
|
|
665
|
+
try {
|
|
666
|
+
await onConfirm?.();
|
|
667
|
+
onOpenChange?.(false);
|
|
668
|
+
} finally {
|
|
669
|
+
setPending(false);
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
const handleCancel = () => {
|
|
674
|
+
onCancel?.();
|
|
675
|
+
onOpenChange?.(false);
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
const isBusy = pending || loading;
|
|
679
|
+
|
|
680
|
+
return (
|
|
681
|
+
<div className="atlas-confirm-dialog fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
682
|
+
<div
|
|
683
|
+
className="fixed inset-0 bg-black/50 animate-in fade-in-0"
|
|
684
|
+
onClick={handleCancel}
|
|
685
|
+
aria-hidden="true"
|
|
686
|
+
/>
|
|
687
|
+
<div
|
|
688
|
+
role="alertdialog"
|
|
689
|
+
aria-modal="true"
|
|
690
|
+
aria-labelledby="confirm-title"
|
|
691
|
+
aria-describedby="confirm-description"
|
|
692
|
+
className={cn(
|
|
693
|
+
"relative z-10 w-full max-w-sm rounded-xl border border-border bg-background p-6 shadow-xl",
|
|
694
|
+
"animate-in fade-in-0 zoom-in-95"
|
|
695
|
+
)}
|
|
696
|
+
>
|
|
697
|
+
<div className="flex items-start gap-4">
|
|
698
|
+
<div className={cn(
|
|
699
|
+
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full",
|
|
700
|
+
variant === "danger" ? "bg-destructive/10" : "bg-primary/10"
|
|
701
|
+
)}>
|
|
702
|
+
{variant === "danger" ? (
|
|
703
|
+
<svg className="h-5 w-5 text-destructive" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
704
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
|
705
|
+
</svg>
|
|
706
|
+
) : (
|
|
707
|
+
<svg className="h-5 w-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
708
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
709
|
+
</svg>
|
|
710
|
+
)}
|
|
711
|
+
</div>
|
|
712
|
+
<div>
|
|
713
|
+
<h2 id="confirm-title" className="text-base font-semibold">{title}</h2>
|
|
714
|
+
<p id="confirm-description" className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
715
|
+
</div>
|
|
716
|
+
</div>
|
|
717
|
+
<div className="mt-6 flex justify-end gap-2">
|
|
718
|
+
<button
|
|
719
|
+
type="button"
|
|
720
|
+
onClick={handleCancel}
|
|
721
|
+
disabled={isBusy}
|
|
722
|
+
className="inline-flex h-9 items-center justify-center rounded-md border border-border bg-background px-4 text-sm font-medium hover:bg-accent transition-colors disabled:opacity-50"
|
|
723
|
+
>
|
|
724
|
+
{cancelLabel}
|
|
725
|
+
</button>
|
|
726
|
+
<button
|
|
727
|
+
type="button"
|
|
728
|
+
onClick={handleConfirm}
|
|
729
|
+
disabled={isBusy}
|
|
730
|
+
className={cn(
|
|
731
|
+
"inline-flex h-9 items-center justify-center gap-2 rounded-md px-4 text-sm font-medium transition-colors disabled:opacity-50",
|
|
732
|
+
variant === "danger"
|
|
733
|
+
? "bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
734
|
+
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
|
735
|
+
)}
|
|
736
|
+
>
|
|
737
|
+
{isBusy && (
|
|
738
|
+
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
739
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
740
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
741
|
+
</svg>
|
|
742
|
+
)}
|
|
743
|
+
{confirmLabel}
|
|
744
|
+
</button>
|
|
745
|
+
</div>
|
|
746
|
+
</div>
|
|
747
|
+
</div>
|
|
748
|
+
);
|
|
749
|
+
};
|
|
750
|
+
ConfirmDialog.displayName = "ConfirmDialog";
|
|
751
|
+
|
|
752
|
+
// ─── FloatingActionButton ─────────────────────────────────────────────────
|
|
753
|
+
|
|
754
|
+
export interface FABAction {
|
|
755
|
+
icon: React.ReactNode;
|
|
756
|
+
label: string;
|
|
757
|
+
onClick: () => void;
|
|
758
|
+
disabled?: boolean;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
export interface FloatingActionButtonProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onClick" | "size"> {
|
|
762
|
+
icon?: React.ReactNode;
|
|
763
|
+
label?: string;
|
|
764
|
+
actions?: FABAction[];
|
|
765
|
+
position?: "bottom-right" | "bottom-left" | "bottom-center";
|
|
766
|
+
size?: "sm" | "md" | "lg";
|
|
767
|
+
onClick?: () => void;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const positionMap = {
|
|
771
|
+
"bottom-right": "fixed bottom-6 right-6 z-40",
|
|
772
|
+
"bottom-left": "fixed bottom-6 left-6 z-40",
|
|
773
|
+
"bottom-center": "fixed bottom-6 left-1/2 -translate-x-1/2 z-40",
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
const fabSizes = {
|
|
777
|
+
sm: "h-12 w-12 [&>svg]:h-5 [&>svg]:w-5",
|
|
778
|
+
md: "h-14 w-14 [&>svg]:h-6 [&>svg]:w-6",
|
|
779
|
+
lg: "h-16 w-16 [&>svg]:h-7 [&>svg]:w-7",
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
const FloatingActionButton = React.forwardRef<HTMLDivElement, FloatingActionButtonProps>(
|
|
783
|
+
({ className, icon, label = "Open actions", actions = [], position = "bottom-right", size = "md", onClick, ...props }, ref) => {
|
|
784
|
+
const [open, setOpen] = React.useState(false);
|
|
785
|
+
const hasActions = actions.length > 0;
|
|
786
|
+
|
|
787
|
+
return (
|
|
788
|
+
<div
|
|
789
|
+
ref={ref}
|
|
790
|
+
className={cn(positionMap[position], "flex flex-col-reverse items-center gap-3", className)}
|
|
791
|
+
{...props}
|
|
792
|
+
>
|
|
793
|
+
{hasActions && open && (
|
|
794
|
+
<div className="flex flex-col-reverse gap-2">
|
|
795
|
+
{actions.map((action, i) => (
|
|
796
|
+
<div key={i} className="flex items-center gap-2">
|
|
797
|
+
<span className="rounded-md bg-foreground/90 px-2 py-1 text-xs text-background font-medium shadow whitespace-nowrap">
|
|
798
|
+
{action.label}
|
|
799
|
+
</span>
|
|
800
|
+
<button
|
|
801
|
+
type="button"
|
|
802
|
+
disabled={action.disabled}
|
|
803
|
+
onClick={() => { action.onClick(); setOpen(false); }}
|
|
804
|
+
aria-label={action.label}
|
|
805
|
+
className={cn(
|
|
806
|
+
"flex h-10 w-10 items-center justify-center rounded-full bg-background border border-border",
|
|
807
|
+
"shadow-md text-foreground hover:bg-accent transition-all",
|
|
808
|
+
"[&>svg]:h-4 [&>svg]:w-4",
|
|
809
|
+
"disabled:opacity-50 disabled:pointer-events-none"
|
|
810
|
+
)}
|
|
811
|
+
>
|
|
812
|
+
{action.icon}
|
|
813
|
+
</button>
|
|
814
|
+
</div>
|
|
815
|
+
))}
|
|
816
|
+
</div>
|
|
817
|
+
)}
|
|
818
|
+
<button
|
|
819
|
+
type="button"
|
|
820
|
+
aria-label={label}
|
|
821
|
+
aria-expanded={hasActions ? open : undefined}
|
|
822
|
+
onClick={hasActions ? () => setOpen(!open) : onClick}
|
|
823
|
+
className={cn(
|
|
824
|
+
"flex items-center justify-center rounded-full",
|
|
825
|
+
"bg-primary text-primary-foreground shadow-lg",
|
|
826
|
+
"hover:bg-primary/90 active:scale-95 transition-all",
|
|
827
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
828
|
+
fabSizes[size]
|
|
829
|
+
)}
|
|
830
|
+
>
|
|
831
|
+
{icon ?? (
|
|
832
|
+
<svg
|
|
833
|
+
className={cn("transition-transform duration-200", hasActions && open && "rotate-45")}
|
|
834
|
+
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
|
835
|
+
style={{ width: "1.5rem", height: "1.5rem" }}
|
|
836
|
+
>
|
|
837
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
838
|
+
</svg>
|
|
839
|
+
)}
|
|
840
|
+
</button>
|
|
841
|
+
</div>
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
);
|
|
845
|
+
FloatingActionButton.displayName = "FloatingActionButton";
|
|
846
|
+
|
|
847
|
+
// ─── RichTooltip ──────────────────────────────────────────────────────────
|
|
848
|
+
|
|
849
|
+
export interface RichTooltipProps {
|
|
850
|
+
children: React.ReactNode;
|
|
851
|
+
title?: React.ReactNode;
|
|
852
|
+
description?: React.ReactNode;
|
|
853
|
+
action?: React.ReactNode;
|
|
854
|
+
side?: "top" | "right" | "bottom" | "left";
|
|
855
|
+
open?: boolean;
|
|
856
|
+
defaultOpen?: boolean;
|
|
857
|
+
delayDuration?: number;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const RichTooltip = ({
|
|
861
|
+
children,
|
|
862
|
+
title,
|
|
863
|
+
description,
|
|
864
|
+
action,
|
|
865
|
+
side = "top",
|
|
866
|
+
open: controlledOpen,
|
|
867
|
+
defaultOpen = false,
|
|
868
|
+
delayDuration = 400,
|
|
869
|
+
}: RichTooltipProps) => {
|
|
870
|
+
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
|
|
871
|
+
const isOpen = controlledOpen ?? internalOpen;
|
|
872
|
+
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
873
|
+
|
|
874
|
+
const show = () => {
|
|
875
|
+
timeoutRef.current = setTimeout(() => setInternalOpen(true), delayDuration);
|
|
876
|
+
};
|
|
877
|
+
const hide = () => {
|
|
878
|
+
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
879
|
+
setInternalOpen(false);
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const positionClasses: Record<string, string> = {
|
|
883
|
+
top: "bottom-full left-1/2 -translate-x-1/2 mb-2",
|
|
884
|
+
bottom: "top-full left-1/2 -translate-x-1/2 mt-2",
|
|
885
|
+
left: "right-full top-1/2 -translate-y-1/2 mr-2",
|
|
886
|
+
right: "left-full top-1/2 -translate-y-1/2 ml-2",
|
|
887
|
+
};
|
|
888
|
+
|
|
889
|
+
return (
|
|
890
|
+
<div
|
|
891
|
+
className="atlas-rich-tooltip relative inline-flex"
|
|
892
|
+
onMouseEnter={show}
|
|
893
|
+
onMouseLeave={hide}
|
|
894
|
+
onFocus={show}
|
|
895
|
+
onBlur={hide}
|
|
896
|
+
>
|
|
897
|
+
{children}
|
|
898
|
+
{isOpen && (
|
|
899
|
+
<div
|
|
900
|
+
role="tooltip"
|
|
901
|
+
className={cn(
|
|
902
|
+
"absolute z-50 w-64 rounded-lg border border-border bg-popover p-3 shadow-lg",
|
|
903
|
+
"animate-in fade-in-0 zoom-in-95",
|
|
904
|
+
positionClasses[side]
|
|
905
|
+
)}
|
|
906
|
+
>
|
|
907
|
+
{title && <p className="text-sm font-semibold mb-1">{title}</p>}
|
|
908
|
+
{description && <p className="text-xs text-muted-foreground leading-relaxed">{description}</p>}
|
|
909
|
+
{action && <div className="mt-2 pt-2 border-t border-border">{action}</div>}
|
|
910
|
+
</div>
|
|
911
|
+
)}
|
|
912
|
+
</div>
|
|
913
|
+
);
|
|
914
|
+
};
|
|
915
|
+
RichTooltip.displayName = "RichTooltip";
|
|
916
|
+
|
|
917
|
+
// ─── Tour ─────────────────────────────────────────────────────────────────
|
|
918
|
+
|
|
919
|
+
export interface TourStep {
|
|
920
|
+
target?: string;
|
|
921
|
+
title: string;
|
|
922
|
+
description: React.ReactNode;
|
|
923
|
+
placement?: "top" | "bottom" | "left" | "right";
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
export interface TourProps {
|
|
927
|
+
steps: TourStep[];
|
|
928
|
+
open?: boolean;
|
|
929
|
+
onOpenChange?: (open: boolean) => void;
|
|
930
|
+
onComplete?: () => void;
|
|
931
|
+
currentStep?: number;
|
|
932
|
+
onStepChange?: (step: number) => void;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
const Tour = ({
|
|
936
|
+
steps,
|
|
937
|
+
open,
|
|
938
|
+
onOpenChange,
|
|
939
|
+
onComplete,
|
|
940
|
+
currentStep: controlledStep,
|
|
941
|
+
onStepChange,
|
|
942
|
+
}: TourProps) => {
|
|
943
|
+
const [internalStep, setInternalStep] = React.useState(0);
|
|
944
|
+
const step = controlledStep ?? internalStep;
|
|
945
|
+
const current = steps[step];
|
|
946
|
+
|
|
947
|
+
if (!open || !current) return null;
|
|
948
|
+
|
|
949
|
+
const goNext = () => {
|
|
950
|
+
if (step < steps.length - 1) {
|
|
951
|
+
const next = step + 1;
|
|
952
|
+
onStepChange?.(next);
|
|
953
|
+
setInternalStep(next);
|
|
954
|
+
} else {
|
|
955
|
+
onComplete?.();
|
|
956
|
+
onOpenChange?.(false);
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
const goPrev = () => {
|
|
961
|
+
if (step > 0) {
|
|
962
|
+
const prev = step - 1;
|
|
963
|
+
onStepChange?.(prev);
|
|
964
|
+
setInternalStep(prev);
|
|
965
|
+
}
|
|
966
|
+
};
|
|
967
|
+
|
|
968
|
+
return (
|
|
969
|
+
<div className="atlas-tour fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
970
|
+
<div
|
|
971
|
+
className="fixed inset-0 bg-black/40 animate-in fade-in-0"
|
|
972
|
+
onClick={() => onOpenChange?.(false)}
|
|
973
|
+
aria-hidden="true"
|
|
974
|
+
/>
|
|
975
|
+
<div
|
|
976
|
+
role="dialog"
|
|
977
|
+
aria-modal="true"
|
|
978
|
+
aria-label={`Tour step ${step + 1} of ${steps.length}: ${current.title}`}
|
|
979
|
+
className="relative z-10 w-full max-w-sm rounded-xl border border-border bg-background p-5 shadow-xl animate-in fade-in-0 zoom-in-95"
|
|
980
|
+
>
|
|
981
|
+
<div className="flex items-center justify-between mb-3">
|
|
982
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
983
|
+
Step {step + 1} of {steps.length}
|
|
984
|
+
</span>
|
|
985
|
+
<button
|
|
986
|
+
type="button"
|
|
987
|
+
onClick={() => onOpenChange?.(false)}
|
|
988
|
+
aria-label="Close tour"
|
|
989
|
+
className="rounded p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
|
990
|
+
>
|
|
991
|
+
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
992
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
993
|
+
</svg>
|
|
994
|
+
</button>
|
|
995
|
+
</div>
|
|
996
|
+
|
|
997
|
+
<h3 className="text-base font-semibold mb-1">{current.title}</h3>
|
|
998
|
+
<div className="text-sm text-muted-foreground">{current.description}</div>
|
|
999
|
+
|
|
1000
|
+
<div className="mt-5 flex items-center justify-between gap-3">
|
|
1001
|
+
<div className="flex gap-1">
|
|
1002
|
+
{steps.map((_, i) => (
|
|
1003
|
+
<span
|
|
1004
|
+
key={i}
|
|
1005
|
+
className={cn(
|
|
1006
|
+
"h-1.5 rounded-full transition-all",
|
|
1007
|
+
i === step ? "w-4 bg-primary" : "w-1.5 bg-muted"
|
|
1008
|
+
)}
|
|
1009
|
+
/>
|
|
1010
|
+
))}
|
|
1011
|
+
</div>
|
|
1012
|
+
<div className="flex gap-2">
|
|
1013
|
+
{step > 0 && (
|
|
1014
|
+
<button
|
|
1015
|
+
type="button"
|
|
1016
|
+
onClick={goPrev}
|
|
1017
|
+
className="h-8 px-3 rounded-md border border-border text-sm hover:bg-accent transition-colors"
|
|
1018
|
+
>
|
|
1019
|
+
Back
|
|
1020
|
+
</button>
|
|
1021
|
+
)}
|
|
1022
|
+
<button
|
|
1023
|
+
type="button"
|
|
1024
|
+
onClick={goNext}
|
|
1025
|
+
className="h-8 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
|
|
1026
|
+
>
|
|
1027
|
+
{step === steps.length - 1 ? "Done" : "Next"}
|
|
1028
|
+
</button>
|
|
1029
|
+
</div>
|
|
1030
|
+
</div>
|
|
1031
|
+
</div>
|
|
1032
|
+
</div>
|
|
1033
|
+
);
|
|
1034
|
+
};
|
|
1035
|
+
Tour.displayName = "Tour";
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
export {
|
|
1039
|
+
|
|
1040
|
+
Alert, AlertTitle, AlertDescription,
|
|
1041
|
+
ToastProvider, ToastViewport, Toast, ToastTitle, ToastDescription, ToastClose, ToastAction,
|
|
1042
|
+
Snackbar,
|
|
1043
|
+
Progress,
|
|
1044
|
+
CircularProgress,
|
|
1045
|
+
Skeleton,
|
|
1046
|
+
LoadingSpinner,
|
|
1047
|
+
EmptyState,
|
|
1048
|
+
StatusIndicator,
|
|
1049
|
+
Notification,
|
|
1050
|
+
BannerAlert, ConfirmDialog, FloatingActionButton, RichTooltip, Tour
|
|
1051
|
+
};
|