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,975 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../../utils/cn";
|
|
3
|
+
import { Input } from "../forms";
|
|
4
|
+
|
|
5
|
+
// ─── FileUpload ────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface FileUploadProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
|
|
8
|
+
label?: string;
|
|
9
|
+
hint?: string;
|
|
10
|
+
accept?: string;
|
|
11
|
+
maxSize?: number;
|
|
12
|
+
onFilesChange?: (files: File[]) => void;
|
|
13
|
+
dragDrop?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const FileUpload = React.forwardRef<HTMLInputElement, FileUploadProps>(
|
|
17
|
+
({ className, label, hint, dragDrop = true, onFilesChange, id, ...props }, ref) => {
|
|
18
|
+
const inputId = id ?? React.useId();
|
|
19
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
20
|
+
|
|
21
|
+
const handleDrop = (e: React.DragEvent) => {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
setIsDragging(false);
|
|
24
|
+
const files = Array.from(e.dataTransfer.files);
|
|
25
|
+
onFilesChange?.(files);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className={cn("atlas-file-upload w-full", className)}>
|
|
30
|
+
{dragDrop ? (
|
|
31
|
+
<label
|
|
32
|
+
htmlFor={inputId}
|
|
33
|
+
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
|
34
|
+
onDragLeave={() => setIsDragging(false)}
|
|
35
|
+
onDrop={handleDrop}
|
|
36
|
+
className={cn(
|
|
37
|
+
"flex flex-col items-center justify-center gap-2 w-full",
|
|
38
|
+
"border-2 border-dashed rounded-lg p-8 cursor-pointer",
|
|
39
|
+
"transition-colors text-center",
|
|
40
|
+
isDragging
|
|
41
|
+
? "border-primary bg-primary/5 text-primary"
|
|
42
|
+
: "border-border hover:border-primary/50 hover:bg-muted/50 text-muted-foreground"
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
<svg className="h-8 w-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
46
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
|
47
|
+
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"
|
|
48
|
+
/>
|
|
49
|
+
</svg>
|
|
50
|
+
<div>
|
|
51
|
+
<p className="font-medium text-sm">
|
|
52
|
+
{label ?? "Click to upload or drag and drop"}
|
|
53
|
+
</p>
|
|
54
|
+
{hint && <p className="text-xs mt-0.5">{hint}</p>}
|
|
55
|
+
</div>
|
|
56
|
+
<input
|
|
57
|
+
ref={ref}
|
|
58
|
+
id={inputId}
|
|
59
|
+
type="file"
|
|
60
|
+
className="sr-only"
|
|
61
|
+
onChange={(e) => onFilesChange?.(Array.from(e.target.files ?? []))}
|
|
62
|
+
{...props}
|
|
63
|
+
/>
|
|
64
|
+
</label>
|
|
65
|
+
) : (
|
|
66
|
+
<input
|
|
67
|
+
ref={ref}
|
|
68
|
+
id={inputId}
|
|
69
|
+
type="file"
|
|
70
|
+
className={cn(
|
|
71
|
+
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm",
|
|
72
|
+
"file:mr-3 file:border-0 file:bg-primary file:text-primary-foreground file:rounded file:px-2 file:py-1 file:text-xs file:font-medium",
|
|
73
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
74
|
+
)}
|
|
75
|
+
onChange={(e) => onFilesChange?.(Array.from(e.target.files ?? []))}
|
|
76
|
+
{...props}
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
);
|
|
83
|
+
FileUpload.displayName = "FileUpload";
|
|
84
|
+
|
|
85
|
+
// ─── OTPInput ─────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
export interface OTPInputProps {
|
|
88
|
+
length?: number;
|
|
89
|
+
value?: string;
|
|
90
|
+
onChange?: (value: string) => void;
|
|
91
|
+
invalid?: boolean;
|
|
92
|
+
className?: string;
|
|
93
|
+
inputClassName?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const OTPInput = React.forwardRef<HTMLDivElement, OTPInputProps>(
|
|
97
|
+
({ length = 6, value = "", onChange, invalid, className, inputClassName }, ref) => {
|
|
98
|
+
const inputRefs = React.useRef<(HTMLInputElement | null)[]>([]);
|
|
99
|
+
|
|
100
|
+
const handleChange = (index: number, char: string) => {
|
|
101
|
+
const chars = value.split("");
|
|
102
|
+
chars[index] = char;
|
|
103
|
+
const next = chars.join("").slice(0, length);
|
|
104
|
+
onChange?.(next);
|
|
105
|
+
if (char && index < length - 1) {
|
|
106
|
+
inputRefs.current[index + 1]?.focus();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
111
|
+
if (e.key === "Backspace" && !value[index] && index > 0) {
|
|
112
|
+
inputRefs.current[index - 1]?.focus();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const handlePaste = (e: React.ClipboardEvent) => {
|
|
117
|
+
e.preventDefault();
|
|
118
|
+
const pasted = e.clipboardData.getData("text").slice(0, length);
|
|
119
|
+
onChange?.(pasted);
|
|
120
|
+
inputRefs.current[Math.min(pasted.length, length - 1)]?.focus();
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div ref={ref} className={cn("atlas-otp-input flex gap-2", className)} role="group" aria-label="OTP Input">
|
|
125
|
+
{Array.from({ length }).map((_, i) => (
|
|
126
|
+
<input
|
|
127
|
+
key={i}
|
|
128
|
+
ref={(el) => { inputRefs.current[i] = el; }}
|
|
129
|
+
type="text"
|
|
130
|
+
inputMode="numeric"
|
|
131
|
+
maxLength={1}
|
|
132
|
+
value={value[i] ?? ""}
|
|
133
|
+
onChange={(e) => handleChange(i, e.target.value.replace(/[^0-9]/g, ""))}
|
|
134
|
+
onKeyDown={(e) => handleKeyDown(i, e)}
|
|
135
|
+
onPaste={handlePaste}
|
|
136
|
+
aria-label={`Digit ${i + 1}`}
|
|
137
|
+
className={cn(
|
|
138
|
+
"h-10 w-10 text-center text-base font-semibold rounded-md border",
|
|
139
|
+
"transition-shadow focus:outline-none focus:ring-2 focus:ring-ring",
|
|
140
|
+
invalid ? "border-destructive" : "border-input",
|
|
141
|
+
"bg-background",
|
|
142
|
+
inputClassName
|
|
143
|
+
)}
|
|
144
|
+
/>
|
|
145
|
+
))}
|
|
146
|
+
</div>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
);
|
|
150
|
+
OTPInput.displayName = "OTPInput";
|
|
151
|
+
|
|
152
|
+
// ─── ColorPicker ──────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
export interface ColorPickerProps {
|
|
155
|
+
value?: string;
|
|
156
|
+
onChange?: (color: string) => void;
|
|
157
|
+
swatches?: string[];
|
|
158
|
+
className?: string;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const DEFAULT_SWATCHES = [
|
|
162
|
+
"#ef4444","#f97316","#eab308","#22c55e",
|
|
163
|
+
"#3b82f6","#8b5cf6","#ec4899","#64748b",
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
const ColorPicker = ({ value = "#3b82f6", onChange, swatches = DEFAULT_SWATCHES, className }: ColorPickerProps) => (
|
|
167
|
+
<div className={cn("atlas-color-picker flex flex-col gap-3", className)}>
|
|
168
|
+
<div className="flex items-center gap-3">
|
|
169
|
+
<div
|
|
170
|
+
className="h-9 w-9 rounded-md border border-border shadow-sm shrink-0"
|
|
171
|
+
style={{ backgroundColor: value }}
|
|
172
|
+
aria-hidden="true"
|
|
173
|
+
/>
|
|
174
|
+
<input
|
|
175
|
+
type="color"
|
|
176
|
+
value={value}
|
|
177
|
+
onChange={(e) => onChange?.(e.target.value)}
|
|
178
|
+
className="sr-only"
|
|
179
|
+
id="atlas-color-input"
|
|
180
|
+
aria-label="Custom color"
|
|
181
|
+
/>
|
|
182
|
+
<label htmlFor="atlas-color-input" className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
|
|
183
|
+
Pick color
|
|
184
|
+
</label>
|
|
185
|
+
<input
|
|
186
|
+
type="text"
|
|
187
|
+
value={value}
|
|
188
|
+
onChange={(e) => onChange?.(e.target.value)}
|
|
189
|
+
className="flex-1 h-9 rounded-md border border-input bg-background px-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-ring"
|
|
190
|
+
aria-label="Hex color value"
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
{swatches.length > 0 && (
|
|
194
|
+
<div className="flex flex-wrap gap-1.5" role="group" aria-label="Color swatches">
|
|
195
|
+
{swatches.map((swatch) => (
|
|
196
|
+
<button
|
|
197
|
+
key={swatch}
|
|
198
|
+
type="button"
|
|
199
|
+
onClick={() => onChange?.(swatch)}
|
|
200
|
+
aria-label={`Select color ${swatch}`}
|
|
201
|
+
aria-pressed={value === swatch}
|
|
202
|
+
className={cn(
|
|
203
|
+
"h-6 w-6 rounded border-2 transition-transform hover:scale-110",
|
|
204
|
+
value === swatch ? "border-foreground" : "border-transparent"
|
|
205
|
+
)}
|
|
206
|
+
style={{ backgroundColor: swatch }}
|
|
207
|
+
/>
|
|
208
|
+
))}
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
);
|
|
213
|
+
ColorPicker.displayName = "ColorPicker";
|
|
214
|
+
|
|
215
|
+
// ─── SearchInput ──────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
export interface SearchInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "size"> {
|
|
218
|
+
onClear?: () => void;
|
|
219
|
+
loading?: boolean;
|
|
220
|
+
size?: "sm" | "md" | "lg";
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
|
|
224
|
+
({ className, onClear, loading, value, size = "md", ...props }, ref) => (
|
|
225
|
+
<div className={cn("atlas-search-input relative flex items-center w-full", className)}>
|
|
226
|
+
<span className="absolute left-3 text-muted-foreground pointer-events-none">
|
|
227
|
+
{loading ? (
|
|
228
|
+
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
229
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
230
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
231
|
+
</svg>
|
|
232
|
+
) : (
|
|
233
|
+
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
234
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
235
|
+
</svg>
|
|
236
|
+
)}
|
|
237
|
+
</span>
|
|
238
|
+
<input
|
|
239
|
+
ref={ref}
|
|
240
|
+
type="search"
|
|
241
|
+
value={value}
|
|
242
|
+
className={cn(
|
|
243
|
+
"flex w-full rounded-md border border-input bg-background text-sm",
|
|
244
|
+
"ring-offset-background placeholder:text-muted-foreground",
|
|
245
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
246
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
247
|
+
"pl-9",
|
|
248
|
+
value && onClear ? "pr-8" : "pr-3",
|
|
249
|
+
size === "sm" && "h-8 text-xs",
|
|
250
|
+
size === "md" && "h-9",
|
|
251
|
+
size === "lg" && "h-10",
|
|
252
|
+
)}
|
|
253
|
+
{...props}
|
|
254
|
+
/>
|
|
255
|
+
{value && onClear && (
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
onClick={onClear}
|
|
259
|
+
aria-label="Clear search"
|
|
260
|
+
className="absolute right-2.5 text-muted-foreground hover:text-foreground transition-colors"
|
|
261
|
+
>
|
|
262
|
+
<svg className="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
263
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
|
|
264
|
+
</svg>
|
|
265
|
+
</button>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
)
|
|
269
|
+
);
|
|
270
|
+
SearchInput.displayName = "SearchInput";
|
|
271
|
+
|
|
272
|
+
// ─── PasswordInput ────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
export interface PasswordInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "size"> {
|
|
275
|
+
invalid?: boolean;
|
|
276
|
+
size?: "sm" | "md" | "lg";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
|
|
280
|
+
({ className, invalid, size = "md", ...props }, ref) => {
|
|
281
|
+
const [show, setShow] = React.useState(false);
|
|
282
|
+
|
|
283
|
+
return (
|
|
284
|
+
<div className={cn("atlas-password-input relative flex items-center w-full", className)}>
|
|
285
|
+
<input
|
|
286
|
+
ref={ref}
|
|
287
|
+
type={show ? "text" : "password"}
|
|
288
|
+
className={cn(
|
|
289
|
+
"flex w-full rounded-md border border-input bg-background text-sm pr-10",
|
|
290
|
+
"ring-offset-background placeholder:text-muted-foreground",
|
|
291
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
292
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
293
|
+
size === "sm" && "h-8 px-2.5 text-xs",
|
|
294
|
+
size === "md" && "h-9 px-3",
|
|
295
|
+
size === "lg" && "h-10 px-4",
|
|
296
|
+
invalid && "border-destructive focus-visible:ring-destructive",
|
|
297
|
+
)}
|
|
298
|
+
aria-invalid={invalid}
|
|
299
|
+
{...props}
|
|
300
|
+
/>
|
|
301
|
+
<button
|
|
302
|
+
type="button"
|
|
303
|
+
onClick={() => setShow(!show)}
|
|
304
|
+
aria-label={show ? "Hide password" : "Show password"}
|
|
305
|
+
className="absolute right-3 text-muted-foreground hover:text-foreground transition-colors"
|
|
306
|
+
>
|
|
307
|
+
{show ? (
|
|
308
|
+
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
309
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
|
310
|
+
</svg>
|
|
311
|
+
) : (
|
|
312
|
+
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
313
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
314
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
315
|
+
</svg>
|
|
316
|
+
)}
|
|
317
|
+
</button>
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
);
|
|
322
|
+
PasswordInput.displayName = "PasswordInput";
|
|
323
|
+
|
|
324
|
+
// ─── FormField ────────────────────────────────────────────────────────────
|
|
325
|
+
|
|
326
|
+
export interface FormFieldProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
327
|
+
required?: boolean;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const FormField = React.forwardRef<HTMLDivElement, FormFieldProps>(
|
|
331
|
+
({ className, ...props }, ref) => (
|
|
332
|
+
<div ref={ref} className={cn("atlas-form-field grid gap-1.5 w-full", className)} {...props} />
|
|
333
|
+
)
|
|
334
|
+
);
|
|
335
|
+
FormField.displayName = "FormField";
|
|
336
|
+
|
|
337
|
+
// ─── FormLabel ────────────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
export interface FormLabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {
|
|
340
|
+
required?: boolean;
|
|
341
|
+
optional?: boolean;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const FormLabel = React.forwardRef<HTMLLabelElement, FormLabelProps>(
|
|
345
|
+
({ className, required, optional, children, ...props }, ref) => (
|
|
346
|
+
<label
|
|
347
|
+
ref={ref}
|
|
348
|
+
className={cn("atlas-form-label text-sm font-medium leading-none", className)}
|
|
349
|
+
{...props}
|
|
350
|
+
>
|
|
351
|
+
{children}
|
|
352
|
+
{required && <span className="ml-0.5 text-destructive" aria-hidden="true">*</span>}
|
|
353
|
+
{optional && <span className="ml-1 text-xs font-normal text-muted-foreground">(optional)</span>}
|
|
354
|
+
</label>
|
|
355
|
+
)
|
|
356
|
+
);
|
|
357
|
+
FormLabel.displayName = "FormLabel";
|
|
358
|
+
|
|
359
|
+
// ─── FormError ────────────────────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
export interface FormErrorProps extends React.HTMLAttributes<HTMLParagraphElement> {}
|
|
362
|
+
|
|
363
|
+
const FormError = React.forwardRef<HTMLParagraphElement, FormErrorProps>(
|
|
364
|
+
({ className, children, ...props }, ref) => {
|
|
365
|
+
if (!children) return null;
|
|
366
|
+
return (
|
|
367
|
+
<p
|
|
368
|
+
ref={ref}
|
|
369
|
+
role="alert"
|
|
370
|
+
aria-live="polite"
|
|
371
|
+
className={cn("atlas-form-error flex items-center gap-1.5 text-xs text-destructive", className)}
|
|
372
|
+
{...props}
|
|
373
|
+
>
|
|
374
|
+
<svg className="h-3.5 w-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
375
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
376
|
+
</svg>
|
|
377
|
+
{children}
|
|
378
|
+
</p>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
);
|
|
382
|
+
FormError.displayName = "FormError";
|
|
383
|
+
|
|
384
|
+
// ─── Combobox ─────────────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
export interface ComboboxOption {
|
|
387
|
+
value: string;
|
|
388
|
+
label: string;
|
|
389
|
+
disabled?: boolean;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export interface ComboboxProps {
|
|
393
|
+
options: ComboboxOption[];
|
|
394
|
+
value?: string;
|
|
395
|
+
onChange?: (value: string) => void;
|
|
396
|
+
placeholder?: string;
|
|
397
|
+
searchPlaceholder?: string;
|
|
398
|
+
emptyText?: string;
|
|
399
|
+
className?: string;
|
|
400
|
+
disabled?: boolean;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const Combobox = ({ options, value, onChange, placeholder = "Select option...", searchPlaceholder = "Search...", emptyText = "No results found.", className, disabled }: ComboboxProps) => {
|
|
404
|
+
const [open, setOpen] = React.useState(false);
|
|
405
|
+
const [search, setSearch] = React.useState("");
|
|
406
|
+
|
|
407
|
+
const filtered = options.filter((o) =>
|
|
408
|
+
o.label.toLowerCase().includes(search.toLowerCase())
|
|
409
|
+
);
|
|
410
|
+
const selected = options.find((o) => o.value === value);
|
|
411
|
+
|
|
412
|
+
return (
|
|
413
|
+
<div className={cn("atlas-combobox relative w-full", className)}>
|
|
414
|
+
<button
|
|
415
|
+
type="button"
|
|
416
|
+
role="combobox"
|
|
417
|
+
aria-expanded={open}
|
|
418
|
+
aria-haspopup="listbox"
|
|
419
|
+
disabled={disabled}
|
|
420
|
+
onClick={() => setOpen(!open)}
|
|
421
|
+
className={cn(
|
|
422
|
+
"flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm",
|
|
423
|
+
"focus:outline-none focus:ring-2 focus:ring-ring",
|
|
424
|
+
"disabled:cursor-not-allowed disabled:opacity-50"
|
|
425
|
+
)}
|
|
426
|
+
>
|
|
427
|
+
<span className={selected ? "text-foreground" : "text-muted-foreground"}>
|
|
428
|
+
{selected?.label ?? placeholder}
|
|
429
|
+
</span>
|
|
430
|
+
<svg className="h-4 w-4 opacity-50 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
431
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" />
|
|
432
|
+
</svg>
|
|
433
|
+
</button>
|
|
434
|
+
{open && (
|
|
435
|
+
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border border-border bg-popover shadow-md">
|
|
436
|
+
<div className="p-1 border-b border-border">
|
|
437
|
+
<input
|
|
438
|
+
type="text"
|
|
439
|
+
value={search}
|
|
440
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
441
|
+
placeholder={searchPlaceholder}
|
|
442
|
+
className="w-full px-2 py-1.5 text-sm bg-transparent outline-none placeholder:text-muted-foreground"
|
|
443
|
+
autoFocus
|
|
444
|
+
/>
|
|
445
|
+
</div>
|
|
446
|
+
<div role="listbox" className="max-h-60 overflow-y-auto p-1">
|
|
447
|
+
{filtered.length === 0 ? (
|
|
448
|
+
<p className="py-6 text-center text-sm text-muted-foreground">{emptyText}</p>
|
|
449
|
+
) : (
|
|
450
|
+
filtered.map((option) => (
|
|
451
|
+
<button
|
|
452
|
+
key={option.value}
|
|
453
|
+
role="option"
|
|
454
|
+
aria-selected={option.value === value}
|
|
455
|
+
disabled={option.disabled}
|
|
456
|
+
onClick={() => { onChange?.(option.value); setOpen(false); setSearch(""); }}
|
|
457
|
+
className={cn(
|
|
458
|
+
"relative flex w-full cursor-default items-center rounded-sm px-2 py-1.5 pl-8 text-sm",
|
|
459
|
+
"hover:bg-accent hover:text-accent-foreground outline-none",
|
|
460
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
461
|
+
option.value === value && "bg-accent"
|
|
462
|
+
)}
|
|
463
|
+
>
|
|
464
|
+
{option.value === value && (
|
|
465
|
+
<svg className="absolute left-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
466
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
|
467
|
+
</svg>
|
|
468
|
+
)}
|
|
469
|
+
{option.label}
|
|
470
|
+
</button>
|
|
471
|
+
))
|
|
472
|
+
)}
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
)}
|
|
476
|
+
</div>
|
|
477
|
+
);
|
|
478
|
+
};
|
|
479
|
+
Combobox.displayName = "Combobox";
|
|
480
|
+
|
|
481
|
+
// ─── MultiSelect ──────────────────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
export interface MultiSelectProps {
|
|
484
|
+
options: ComboboxOption[];
|
|
485
|
+
value?: string[];
|
|
486
|
+
onChange?: (value: string[]) => void;
|
|
487
|
+
placeholder?: string;
|
|
488
|
+
maxDisplay?: number;
|
|
489
|
+
className?: string;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const MultiSelect = ({ options, value = [], onChange, placeholder = "Select...", maxDisplay = 3, className }: MultiSelectProps) => {
|
|
493
|
+
const [open, setOpen] = React.useState(false);
|
|
494
|
+
|
|
495
|
+
const toggle = (optValue: string) => {
|
|
496
|
+
onChange?.(value.includes(optValue) ? value.filter((v) => v !== optValue) : [...value, optValue]);
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const selectedLabels = value
|
|
500
|
+
.slice(0, maxDisplay)
|
|
501
|
+
.map((v) => options.find((o) => o.value === v)?.label)
|
|
502
|
+
.filter(Boolean);
|
|
503
|
+
|
|
504
|
+
return (
|
|
505
|
+
<div className={cn("atlas-multi-select relative w-full", className)}>
|
|
506
|
+
<button
|
|
507
|
+
type="button"
|
|
508
|
+
onClick={() => setOpen(!open)}
|
|
509
|
+
className="flex min-h-[2.25rem] w-full flex-wrap items-center gap-1 rounded-md border border-input bg-background px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
510
|
+
>
|
|
511
|
+
{value.length === 0 ? (
|
|
512
|
+
<span className="text-muted-foreground">{placeholder}</span>
|
|
513
|
+
) : (
|
|
514
|
+
<>
|
|
515
|
+
{selectedLabels.map((label, i) => (
|
|
516
|
+
<span key={i} className="inline-flex items-center gap-0.5 rounded bg-secondary px-1.5 py-0.5 text-xs font-medium">
|
|
517
|
+
{label}
|
|
518
|
+
<svg className="h-3 w-3 cursor-pointer" onClick={(e) => { e.stopPropagation(); const v = options.find((o) => o.label === label)?.value; if (v) toggle(v); }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
519
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
|
|
520
|
+
</svg>
|
|
521
|
+
</span>
|
|
522
|
+
))}
|
|
523
|
+
{value.length > maxDisplay && (
|
|
524
|
+
<span className="text-xs text-muted-foreground">+{value.length - maxDisplay} more</span>
|
|
525
|
+
)}
|
|
526
|
+
</>
|
|
527
|
+
)}
|
|
528
|
+
</button>
|
|
529
|
+
{open && (
|
|
530
|
+
<div className="absolute top-full left-0 z-50 mt-1 w-full rounded-md border border-border bg-popover shadow-md">
|
|
531
|
+
<div role="listbox" aria-multiselectable="true" className="max-h-60 overflow-y-auto p-1">
|
|
532
|
+
{options.map((option) => (
|
|
533
|
+
<button
|
|
534
|
+
key={option.value}
|
|
535
|
+
role="option"
|
|
536
|
+
aria-selected={value.includes(option.value)}
|
|
537
|
+
onClick={() => toggle(option.value)}
|
|
538
|
+
className="relative flex w-full cursor-default items-center rounded-sm px-2 py-1.5 pl-8 text-sm hover:bg-accent"
|
|
539
|
+
>
|
|
540
|
+
{value.includes(option.value) && (
|
|
541
|
+
<svg className="absolute left-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
542
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
|
543
|
+
</svg>
|
|
544
|
+
)}
|
|
545
|
+
{option.label}
|
|
546
|
+
</button>
|
|
547
|
+
))}
|
|
548
|
+
</div>
|
|
549
|
+
</div>
|
|
550
|
+
)}
|
|
551
|
+
</div>
|
|
552
|
+
);
|
|
553
|
+
};
|
|
554
|
+
MultiSelect.displayName = "MultiSelect";
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
// ═══════════════════════════════════════════════════════════════
|
|
558
|
+
// New in v0.1.2
|
|
559
|
+
// ═══════════════════════════════════════════════════════════════
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
// ─── PhoneInput ───────────────────────────────────────────────────────────
|
|
563
|
+
|
|
564
|
+
const COUNTRY_CODES = [
|
|
565
|
+
{ code: "US", dial: "+1", flag: "US" },
|
|
566
|
+
{ code: "GB", dial: "+44", flag: "GB" },
|
|
567
|
+
{ code: "AU", dial: "+61", flag: "AU" },
|
|
568
|
+
{ code: "CA", dial: "+1", flag: "CA" },
|
|
569
|
+
{ code: "DE", dial: "+49", flag: "DE" },
|
|
570
|
+
{ code: "FR", dial: "+33", flag: "FR" },
|
|
571
|
+
{ code: "IN", dial: "+91", flag: "IN" },
|
|
572
|
+
{ code: "JP", dial: "+81", flag: "JP" },
|
|
573
|
+
{ code: "CN", dial: "+86", flag: "CN" },
|
|
574
|
+
{ code: "BR", dial: "+55", flag: "BR" },
|
|
575
|
+
{ code: "MX", dial: "+52", flag: "MX" },
|
|
576
|
+
{ code: "PH", dial: "+63", flag: "PH" },
|
|
577
|
+
{ code: "SG", dial: "+65", flag: "SG" },
|
|
578
|
+
{ code: "ZA", dial: "+27", flag: "ZA" },
|
|
579
|
+
{ code: "NG", dial: "+234", flag: "NG" },
|
|
580
|
+
];
|
|
581
|
+
|
|
582
|
+
export interface PhoneInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange" | "value" | "size"> {
|
|
583
|
+
value?: string;
|
|
584
|
+
onChange?: (value: string) => void;
|
|
585
|
+
defaultCountry?: string;
|
|
586
|
+
invalid?: boolean;
|
|
587
|
+
size?: "sm" | "md" | "lg";
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
|
|
591
|
+
({ className, value = "", onChange, defaultCountry = "US", invalid, size = "md", disabled, ...props }, ref) => {
|
|
592
|
+
const [country, setCountry] = React.useState(
|
|
593
|
+
COUNTRY_CODES.find((c) => c.code === defaultCountry) ?? COUNTRY_CODES[0]
|
|
594
|
+
);
|
|
595
|
+
const [open, setOpen] = React.useState(false);
|
|
596
|
+
const containerRef = React.useRef<HTMLDivElement>(null);
|
|
597
|
+
|
|
598
|
+
React.useEffect(() => {
|
|
599
|
+
const handler = (e: MouseEvent) => {
|
|
600
|
+
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
|
601
|
+
setOpen(false);
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
document.addEventListener("mousedown", handler);
|
|
605
|
+
return () => document.removeEventListener("mousedown", handler);
|
|
606
|
+
}, []);
|
|
607
|
+
|
|
608
|
+
return (
|
|
609
|
+
<div ref={containerRef} className={cn("atlas-phone-input relative flex w-full", className)}>
|
|
610
|
+
<button
|
|
611
|
+
type="button"
|
|
612
|
+
disabled={disabled}
|
|
613
|
+
onClick={() => setOpen(!open)}
|
|
614
|
+
className={cn(
|
|
615
|
+
"flex shrink-0 items-center gap-1.5 rounded-l-md border border-r-0 border-input bg-background px-3",
|
|
616
|
+
"hover:bg-accent transition-colors focus:outline-none focus:ring-2 focus:ring-ring",
|
|
617
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
618
|
+
size === "sm" && "h-8 text-xs",
|
|
619
|
+
size === "md" && "h-9 text-sm",
|
|
620
|
+
size === "lg" && "h-10 text-sm",
|
|
621
|
+
invalid && "border-destructive"
|
|
622
|
+
)}
|
|
623
|
+
aria-haspopup="listbox"
|
|
624
|
+
aria-expanded={open}
|
|
625
|
+
aria-label="Select country code"
|
|
626
|
+
>
|
|
627
|
+
<span className="text-base leading-none">{country.dial}</span>
|
|
628
|
+
<svg className="h-3 w-3 opacity-50 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
629
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
630
|
+
</svg>
|
|
631
|
+
</button>
|
|
632
|
+
|
|
633
|
+
<input
|
|
634
|
+
ref={ref}
|
|
635
|
+
type="tel"
|
|
636
|
+
value={value}
|
|
637
|
+
disabled={disabled}
|
|
638
|
+
onChange={(e) => onChange?.(e.target.value)}
|
|
639
|
+
aria-invalid={invalid}
|
|
640
|
+
className={cn(
|
|
641
|
+
"flex w-full rounded-r-md border border-input bg-background px-3 text-sm",
|
|
642
|
+
"placeholder:text-muted-foreground",
|
|
643
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
644
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
645
|
+
size === "sm" && "h-8 text-xs",
|
|
646
|
+
size === "md" && "h-9",
|
|
647
|
+
size === "lg" && "h-10",
|
|
648
|
+
invalid && "border-destructive focus-visible:ring-destructive"
|
|
649
|
+
)}
|
|
650
|
+
{...props}
|
|
651
|
+
/>
|
|
652
|
+
|
|
653
|
+
{open && (
|
|
654
|
+
<div
|
|
655
|
+
role="listbox"
|
|
656
|
+
aria-label="Country codes"
|
|
657
|
+
className="absolute top-full left-0 z-50 mt-1 w-48 rounded-md border border-border bg-popover shadow-md overflow-y-auto max-h-56"
|
|
658
|
+
>
|
|
659
|
+
{COUNTRY_CODES.map((c) => (
|
|
660
|
+
<button
|
|
661
|
+
key={c.code}
|
|
662
|
+
type="button"
|
|
663
|
+
role="option"
|
|
664
|
+
aria-selected={c.code === country.code}
|
|
665
|
+
onClick={() => { setCountry(c); setOpen(false); }}
|
|
666
|
+
className={cn(
|
|
667
|
+
"flex w-full items-center gap-2.5 px-3 py-2 text-sm hover:bg-accent transition-colors",
|
|
668
|
+
c.code === country.code && "bg-accent"
|
|
669
|
+
)}
|
|
670
|
+
>
|
|
671
|
+
<span className="font-mono text-xs text-muted-foreground w-10 shrink-0">{c.dial}</span>
|
|
672
|
+
<span>{c.code}</span>
|
|
673
|
+
</button>
|
|
674
|
+
))}
|
|
675
|
+
</div>
|
|
676
|
+
)}
|
|
677
|
+
</div>
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
);
|
|
681
|
+
PhoneInput.displayName = "PhoneInput";
|
|
682
|
+
|
|
683
|
+
// ─── TagInput ─────────────────────────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
export interface TagInputProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "size"> {
|
|
686
|
+
value?: string[];
|
|
687
|
+
onChange?: (tags: string[]) => void;
|
|
688
|
+
placeholder?: string;
|
|
689
|
+
maxTags?: number;
|
|
690
|
+
invalid?: boolean;
|
|
691
|
+
disabled?: boolean;
|
|
692
|
+
allowDuplicates?: boolean;
|
|
693
|
+
size?: "sm" | "md" | "lg";
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
const TagInput = React.forwardRef<HTMLDivElement, TagInputProps>(
|
|
697
|
+
({
|
|
698
|
+
className,
|
|
699
|
+
value = [],
|
|
700
|
+
onChange,
|
|
701
|
+
placeholder = "Add tag...",
|
|
702
|
+
maxTags,
|
|
703
|
+
invalid,
|
|
704
|
+
disabled,
|
|
705
|
+
allowDuplicates = false,
|
|
706
|
+
size = "md",
|
|
707
|
+
...props
|
|
708
|
+
}, ref) => {
|
|
709
|
+
const [input, setInput] = React.useState("");
|
|
710
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
711
|
+
|
|
712
|
+
const addTag = (tag: string) => {
|
|
713
|
+
const trimmed = tag.trim();
|
|
714
|
+
if (!trimmed) return;
|
|
715
|
+
if (!allowDuplicates && value.includes(trimmed)) return;
|
|
716
|
+
if (maxTags && value.length >= maxTags) return;
|
|
717
|
+
onChange?.([...value, trimmed]);
|
|
718
|
+
setInput("");
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
const removeTag = (index: number) => {
|
|
722
|
+
onChange?.(value.filter((_, i) => i !== index));
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
726
|
+
if (e.key === "Enter" || e.key === ",") {
|
|
727
|
+
e.preventDefault();
|
|
728
|
+
addTag(input);
|
|
729
|
+
}
|
|
730
|
+
if (e.key === "Backspace" && !input && value.length > 0) {
|
|
731
|
+
removeTag(value.length - 1);
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
|
|
735
|
+
return (
|
|
736
|
+
<div
|
|
737
|
+
ref={ref}
|
|
738
|
+
role="group"
|
|
739
|
+
aria-label="Tag input"
|
|
740
|
+
onClick={() => inputRef.current?.focus()}
|
|
741
|
+
className={cn(
|
|
742
|
+
"atlas-tag-input flex flex-wrap gap-1.5 w-full rounded-md border border-input bg-background px-3 py-2",
|
|
743
|
+
"cursor-text transition-shadow",
|
|
744
|
+
"focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
|
|
745
|
+
size === "sm" && "min-h-[2rem] text-xs",
|
|
746
|
+
size === "md" && "min-h-[2.25rem] text-sm",
|
|
747
|
+
size === "lg" && "min-h-[2.5rem] text-sm",
|
|
748
|
+
invalid && "border-destructive focus-within:ring-destructive",
|
|
749
|
+
disabled && "opacity-50 cursor-not-allowed",
|
|
750
|
+
className
|
|
751
|
+
)}
|
|
752
|
+
{...props}
|
|
753
|
+
>
|
|
754
|
+
{value.map((tag, i) => (
|
|
755
|
+
<span
|
|
756
|
+
key={i}
|
|
757
|
+
className="inline-flex items-center gap-1 rounded bg-secondary px-1.5 py-0.5 text-xs font-medium"
|
|
758
|
+
>
|
|
759
|
+
{tag}
|
|
760
|
+
{!disabled && (
|
|
761
|
+
<button
|
|
762
|
+
type="button"
|
|
763
|
+
onClick={(e) => { e.stopPropagation(); removeTag(i); }}
|
|
764
|
+
aria-label={`Remove ${tag}`}
|
|
765
|
+
className="rounded-full hover:bg-muted-foreground/20 p-0.5 transition-colors"
|
|
766
|
+
>
|
|
767
|
+
<svg className="h-2.5 w-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
768
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
|
|
769
|
+
</svg>
|
|
770
|
+
</button>
|
|
771
|
+
)}
|
|
772
|
+
</span>
|
|
773
|
+
))}
|
|
774
|
+
<input
|
|
775
|
+
ref={inputRef}
|
|
776
|
+
type="text"
|
|
777
|
+
value={input}
|
|
778
|
+
disabled={disabled}
|
|
779
|
+
placeholder={value.length === 0 ? placeholder : undefined}
|
|
780
|
+
onChange={(e) => setInput(e.target.value)}
|
|
781
|
+
onKeyDown={handleKeyDown}
|
|
782
|
+
onBlur={() => { if (input) addTag(input); }}
|
|
783
|
+
className="flex-1 min-w-[80px] bg-transparent outline-none placeholder:text-muted-foreground text-sm disabled:cursor-not-allowed"
|
|
784
|
+
aria-label="Type and press Enter to add tag"
|
|
785
|
+
/>
|
|
786
|
+
</div>
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
);
|
|
790
|
+
TagInput.displayName = "TagInput";
|
|
791
|
+
|
|
792
|
+
// ─── CurrencyInput ────────────────────────────────────────────────────────
|
|
793
|
+
|
|
794
|
+
export interface CurrencyInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "onChange" | "value" | "type" | "size"> {
|
|
795
|
+
value?: number | string;
|
|
796
|
+
onChange?: (value: number | undefined) => void;
|
|
797
|
+
currency?: string;
|
|
798
|
+
locale?: string;
|
|
799
|
+
invalid?: boolean;
|
|
800
|
+
size?: "sm" | "md" | "lg";
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const CurrencyInput = React.forwardRef<HTMLInputElement, CurrencyInputProps>(
|
|
804
|
+
({ className, value, onChange, currency = "USD", locale = "en-US", invalid, size = "md", disabled, ...props }, ref) => {
|
|
805
|
+
const [display, setDisplay] = React.useState(
|
|
806
|
+
value !== undefined && value !== "" ? String(value) : ""
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
810
|
+
const raw = e.target.value.replace(/[^0-9.]/g, "");
|
|
811
|
+
setDisplay(raw);
|
|
812
|
+
const num = parseFloat(raw);
|
|
813
|
+
onChange?.(isNaN(num) ? undefined : num);
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const symbol = new Intl.NumberFormat(locale, { style: "currency", currency })
|
|
817
|
+
.formatToParts(0)
|
|
818
|
+
.find((p) => p.type === "currency")?.value ?? "$";
|
|
819
|
+
|
|
820
|
+
return (
|
|
821
|
+
<div className={cn("atlas-currency-input relative flex items-center w-full", className)}>
|
|
822
|
+
<span className={cn(
|
|
823
|
+
"absolute left-3 text-muted-foreground select-none pointer-events-none",
|
|
824
|
+
size === "sm" && "text-xs",
|
|
825
|
+
size === "md" && "text-sm",
|
|
826
|
+
size === "lg" && "text-sm",
|
|
827
|
+
)}>
|
|
828
|
+
{symbol}
|
|
829
|
+
</span>
|
|
830
|
+
<input
|
|
831
|
+
ref={ref}
|
|
832
|
+
type="text"
|
|
833
|
+
inputMode="decimal"
|
|
834
|
+
value={display}
|
|
835
|
+
disabled={disabled}
|
|
836
|
+
onChange={handleChange}
|
|
837
|
+
aria-invalid={invalid}
|
|
838
|
+
className={cn(
|
|
839
|
+
"flex w-full rounded-md border border-input bg-background text-sm",
|
|
840
|
+
"placeholder:text-muted-foreground",
|
|
841
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
|
842
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
843
|
+
"pl-8 pr-3",
|
|
844
|
+
size === "sm" && "h-8 text-xs",
|
|
845
|
+
size === "md" && "h-9",
|
|
846
|
+
size === "lg" && "h-10",
|
|
847
|
+
invalid && "border-destructive focus-visible:ring-destructive",
|
|
848
|
+
)}
|
|
849
|
+
{...props}
|
|
850
|
+
/>
|
|
851
|
+
</div>
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
);
|
|
855
|
+
CurrencyInput.displayName = "CurrencyInput";
|
|
856
|
+
|
|
857
|
+
// ─── RatingInput ──────────────────────────────────────────────────────────
|
|
858
|
+
|
|
859
|
+
export interface RatingInputProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onChange" | "size"> {
|
|
860
|
+
value?: number;
|
|
861
|
+
onChange?: (value: number) => void;
|
|
862
|
+
max?: number;
|
|
863
|
+
size?: "sm" | "md" | "lg";
|
|
864
|
+
disabled?: boolean;
|
|
865
|
+
readOnly?: boolean;
|
|
866
|
+
allowHalf?: boolean;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const ratingIconSizes = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" };
|
|
870
|
+
|
|
871
|
+
const StarIcon = ({ filled, half, className }: { filled: boolean; half?: boolean; className?: string }) => (
|
|
872
|
+
<svg
|
|
873
|
+
className={cn(className, "transition-colors")}
|
|
874
|
+
fill={filled ? "currentColor" : "none"}
|
|
875
|
+
stroke="currentColor"
|
|
876
|
+
viewBox="0 0 24 24"
|
|
877
|
+
aria-hidden="true"
|
|
878
|
+
>
|
|
879
|
+
{half ? (
|
|
880
|
+
<>
|
|
881
|
+
<defs>
|
|
882
|
+
<linearGradient id="half-fill">
|
|
883
|
+
<stop offset="50%" stopColor="currentColor" />
|
|
884
|
+
<stop offset="50%" stopColor="transparent" />
|
|
885
|
+
</linearGradient>
|
|
886
|
+
</defs>
|
|
887
|
+
<path
|
|
888
|
+
strokeLinecap="round"
|
|
889
|
+
strokeLinejoin="round"
|
|
890
|
+
strokeWidth={1.5}
|
|
891
|
+
fill="url(#half-fill)"
|
|
892
|
+
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
|
893
|
+
/>
|
|
894
|
+
</>
|
|
895
|
+
) : (
|
|
896
|
+
<path
|
|
897
|
+
strokeLinecap="round"
|
|
898
|
+
strokeLinejoin="round"
|
|
899
|
+
strokeWidth={1.5}
|
|
900
|
+
d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"
|
|
901
|
+
/>
|
|
902
|
+
)}
|
|
903
|
+
</svg>
|
|
904
|
+
);
|
|
905
|
+
|
|
906
|
+
const RatingInput = React.forwardRef<HTMLDivElement, RatingInputProps>(
|
|
907
|
+
({ className, value = 0, onChange, max = 5, size = "md", disabled, readOnly, ...props }, ref) => {
|
|
908
|
+
const [hovered, setHovered] = React.useState<number | null>(null);
|
|
909
|
+
const display = hovered ?? value;
|
|
910
|
+
|
|
911
|
+
return (
|
|
912
|
+
<div
|
|
913
|
+
ref={ref}
|
|
914
|
+
role="radiogroup"
|
|
915
|
+
aria-label="Rating"
|
|
916
|
+
className={cn("atlas-rating-input flex items-center gap-0.5", className)}
|
|
917
|
+
onMouseLeave={() => setHovered(null)}
|
|
918
|
+
{...props}
|
|
919
|
+
>
|
|
920
|
+
{Array.from({ length: max }, (_, i) => {
|
|
921
|
+
const starValue = i + 1;
|
|
922
|
+
const filled = display >= starValue;
|
|
923
|
+
|
|
924
|
+
return (
|
|
925
|
+
<button
|
|
926
|
+
key={i}
|
|
927
|
+
type="button"
|
|
928
|
+
role="radio"
|
|
929
|
+
aria-checked={value === starValue}
|
|
930
|
+
aria-label={`Rate ${starValue} out of ${max}`}
|
|
931
|
+
disabled={disabled || readOnly}
|
|
932
|
+
onClick={() => onChange?.(starValue)}
|
|
933
|
+
onMouseEnter={() => !readOnly && setHovered(starValue)}
|
|
934
|
+
className={cn(
|
|
935
|
+
"text-yellow-400 transition-transform",
|
|
936
|
+
!disabled && !readOnly && "hover:scale-110 cursor-pointer",
|
|
937
|
+
(disabled || readOnly) && "cursor-default",
|
|
938
|
+
!filled && "text-muted"
|
|
939
|
+
)}
|
|
940
|
+
>
|
|
941
|
+
<StarIcon filled={filled} className={ratingIconSizes[size]} />
|
|
942
|
+
</button>
|
|
943
|
+
);
|
|
944
|
+
})}
|
|
945
|
+
{value > 0 && !readOnly && !disabled && (
|
|
946
|
+
<button
|
|
947
|
+
type="button"
|
|
948
|
+
onClick={() => onChange?.(0)}
|
|
949
|
+
className="ml-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
950
|
+
aria-label="Clear rating"
|
|
951
|
+
>
|
|
952
|
+
Clear
|
|
953
|
+
</button>
|
|
954
|
+
)}
|
|
955
|
+
</div>
|
|
956
|
+
);
|
|
957
|
+
}
|
|
958
|
+
);
|
|
959
|
+
RatingInput.displayName = "RatingInput";
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
export {
|
|
963
|
+
|
|
964
|
+
FileUpload,
|
|
965
|
+
OTPInput,
|
|
966
|
+
ColorPicker,
|
|
967
|
+
SearchInput,
|
|
968
|
+
PasswordInput,
|
|
969
|
+
Combobox,
|
|
970
|
+
MultiSelect,
|
|
971
|
+
FormField,
|
|
972
|
+
FormLabel,
|
|
973
|
+
FormError,
|
|
974
|
+
PhoneInput, TagInput, CurrencyInput, RatingInput
|
|
975
|
+
};
|