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.
Files changed (41) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/LICENSE +21 -0
  3. package/README.md +253 -0
  4. package/dist/cli/index.js +511 -0
  5. package/dist/index.d.mts +1317 -0
  6. package/dist/index.d.ts +1317 -0
  7. package/dist/index.js +5373 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/index.mjs +5130 -0
  10. package/dist/index.mjs.map +1 -0
  11. package/dist/provider.d.mts +15 -0
  12. package/dist/provider.d.ts +15 -0
  13. package/dist/provider.js +1197 -0
  14. package/dist/provider.js.map +1 -0
  15. package/dist/provider.mjs +1161 -0
  16. package/dist/provider.mjs.map +1 -0
  17. package/dist/tailwind.d.ts +25 -0
  18. package/dist/tailwind.js +129 -0
  19. package/package.json +138 -0
  20. package/src/cli/index.ts +303 -0
  21. package/src/cli/registry.ts +139 -0
  22. package/src/components/advanced-forms/index.tsx +975 -0
  23. package/src/components/basic/Button.tsx +135 -0
  24. package/src/components/basic/IconButton.tsx +69 -0
  25. package/src/components/basic/index.tsx +446 -0
  26. package/src/components/data-display/index.tsx +1158 -0
  27. package/src/components/feedback/index.tsx +1051 -0
  28. package/src/components/forms/index.tsx +476 -0
  29. package/src/components/layout/index.tsx +296 -0
  30. package/src/components/media/index.tsx +437 -0
  31. package/src/components/navigation/index.tsx +484 -0
  32. package/src/components/overlay/index.tsx +473 -0
  33. package/src/components/utility/index.tsx +566 -0
  34. package/src/hooks/index.ts +602 -0
  35. package/src/hooks/use-toast.tsx +74 -0
  36. package/src/index.ts +396 -0
  37. package/src/provider.tsx +54 -0
  38. package/src/styles/atlas.css +252 -0
  39. package/src/tailwind.ts +124 -0
  40. package/src/types/index.ts +95 -0
  41. package/src/utils/cn.ts +66 -0
@@ -0,0 +1,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
+ };