@timeax/form-palette 0.0.1

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 (109) hide show
  1. package/.scaffold-cache.json +537 -0
  2. package/package.json +42 -0
  3. package/src/.scaffold-cache.json +544 -0
  4. package/src/adapters/axios.ts +117 -0
  5. package/src/adapters/index.ts +91 -0
  6. package/src/adapters/inertia.ts +187 -0
  7. package/src/core/adapter-registry.ts +87 -0
  8. package/src/core/bound/bind-host.ts +14 -0
  9. package/src/core/bound/observe-bound-field.ts +172 -0
  10. package/src/core/bound/wait-for-bound-field.ts +57 -0
  11. package/src/core/context.ts +23 -0
  12. package/src/core/core-provider.tsx +818 -0
  13. package/src/core/core-root.tsx +72 -0
  14. package/src/core/core-shell.tsx +44 -0
  15. package/src/core/errors/error-strip.tsx +71 -0
  16. package/src/core/errors/index.ts +2 -0
  17. package/src/core/errors/map-error-bag.ts +51 -0
  18. package/src/core/errors/map-zod.ts +39 -0
  19. package/src/core/hooks/use-button.ts +220 -0
  20. package/src/core/hooks/use-core-context.ts +20 -0
  21. package/src/core/hooks/use-core-utility.ts +0 -0
  22. package/src/core/hooks/use-core.ts +13 -0
  23. package/src/core/hooks/use-field.ts +497 -0
  24. package/src/core/hooks/use-optional-field.ts +28 -0
  25. package/src/core/index.ts +0 -0
  26. package/src/core/registry/binder-registry.ts +82 -0
  27. package/src/core/registry/field-registry.ts +187 -0
  28. package/src/core/test.tsx +17 -0
  29. package/src/global.d.ts +14 -0
  30. package/src/index.ts +68 -0
  31. package/src/input/index.ts +4 -0
  32. package/src/input/input-field.tsx +854 -0
  33. package/src/input/input-layout-graph.ts +230 -0
  34. package/src/input/input-props.ts +190 -0
  35. package/src/lib/get-global-countries.ts +87 -0
  36. package/src/lib/utils.ts +6 -0
  37. package/src/presets/index.ts +0 -0
  38. package/src/presets/shadcn-preset.ts +0 -0
  39. package/src/presets/shadcn-variants/checkbox.tsx +849 -0
  40. package/src/presets/shadcn-variants/chips.tsx +756 -0
  41. package/src/presets/shadcn-variants/color.tsx +284 -0
  42. package/src/presets/shadcn-variants/custom.tsx +227 -0
  43. package/src/presets/shadcn-variants/date.tsx +796 -0
  44. package/src/presets/shadcn-variants/file.tsx +764 -0
  45. package/src/presets/shadcn-variants/keyvalue.tsx +556 -0
  46. package/src/presets/shadcn-variants/multiselect.tsx +1132 -0
  47. package/src/presets/shadcn-variants/number.tsx +176 -0
  48. package/src/presets/shadcn-variants/password.tsx +737 -0
  49. package/src/presets/shadcn-variants/phone.tsx +628 -0
  50. package/src/presets/shadcn-variants/radio.tsx +578 -0
  51. package/src/presets/shadcn-variants/select.tsx +956 -0
  52. package/src/presets/shadcn-variants/slider.tsx +622 -0
  53. package/src/presets/shadcn-variants/text.tsx +343 -0
  54. package/src/presets/shadcn-variants/textarea.tsx +66 -0
  55. package/src/presets/shadcn-variants/toggle.tsx +218 -0
  56. package/src/presets/shadcn-variants/treeselect.tsx +784 -0
  57. package/src/presets/ui/badge.tsx +46 -0
  58. package/src/presets/ui/button.tsx +60 -0
  59. package/src/presets/ui/calendar.tsx +214 -0
  60. package/src/presets/ui/checkbox.tsx +115 -0
  61. package/src/presets/ui/custom.tsx +0 -0
  62. package/src/presets/ui/dialog.tsx +141 -0
  63. package/src/presets/ui/field.tsx +246 -0
  64. package/src/presets/ui/input-mask.tsx +739 -0
  65. package/src/presets/ui/input-otp.tsx +77 -0
  66. package/src/presets/ui/input.tsx +1011 -0
  67. package/src/presets/ui/label.tsx +22 -0
  68. package/src/presets/ui/number.tsx +1370 -0
  69. package/src/presets/ui/popover.tsx +46 -0
  70. package/src/presets/ui/radio-group.tsx +43 -0
  71. package/src/presets/ui/scroll-area.tsx +56 -0
  72. package/src/presets/ui/select.tsx +190 -0
  73. package/src/presets/ui/separator.tsx +28 -0
  74. package/src/presets/ui/slider.tsx +61 -0
  75. package/src/presets/ui/switch.tsx +32 -0
  76. package/src/presets/ui/textarea.tsx +634 -0
  77. package/src/presets/ui/time-dropdowns.tsx +350 -0
  78. package/src/schema/adapter.ts +217 -0
  79. package/src/schema/core.ts +429 -0
  80. package/src/schema/field-map.ts +0 -0
  81. package/src/schema/field.ts +224 -0
  82. package/src/schema/index.ts +0 -0
  83. package/src/schema/input-field.ts +260 -0
  84. package/src/schema/presets.ts +0 -0
  85. package/src/schema/variant.ts +216 -0
  86. package/src/variants/core/checkbox.tsx +54 -0
  87. package/src/variants/core/chips.tsx +22 -0
  88. package/src/variants/core/color.tsx +16 -0
  89. package/src/variants/core/custom.tsx +18 -0
  90. package/src/variants/core/date.tsx +25 -0
  91. package/src/variants/core/file.tsx +9 -0
  92. package/src/variants/core/keyvalue.tsx +12 -0
  93. package/src/variants/core/multiselect.tsx +28 -0
  94. package/src/variants/core/number.tsx +115 -0
  95. package/src/variants/core/password.tsx +35 -0
  96. package/src/variants/core/phone.tsx +16 -0
  97. package/src/variants/core/radio.tsx +38 -0
  98. package/src/variants/core/select.tsx +15 -0
  99. package/src/variants/core/slider.tsx +55 -0
  100. package/src/variants/core/text.tsx +114 -0
  101. package/src/variants/core/textarea.tsx +22 -0
  102. package/src/variants/core/toggle.tsx +50 -0
  103. package/src/variants/core/treeselect.tsx +11 -0
  104. package/src/variants/helpers/selection-summary.tsx +236 -0
  105. package/src/variants/index.ts +75 -0
  106. package/src/variants/registry.ts +38 -0
  107. package/src/variants/select-shared.ts +0 -0
  108. package/src/variants/shared.ts +126 -0
  109. package/tsconfig.json +14 -0
@@ -0,0 +1,764 @@
1
+ // src/presets/shadcn-variants/file.tsx
2
+
3
+ import * as React from "react";
4
+ import type { VariantBaseProps, ChangeDetail } from "@/variants/shared";
5
+ import { cn } from "@/lib/utils";
6
+ import { Checkbox } from "@/presets/ui/checkbox";
7
+ import { ScrollArea } from "@/presets/ui/scroll-area";
8
+ import { Button } from "@/presets/ui/button";
9
+ import {
10
+ Popover,
11
+ PopoverContent,
12
+ PopoverTrigger,
13
+ } from "@/presets/ui/popover";
14
+ import {
15
+ FileIcon,
16
+ UploadCloud,
17
+ Trash2,
18
+ CheckCircle2,
19
+ X,
20
+ AlertCircle,
21
+ Loader2,
22
+ ChevronDown,
23
+ Plus,
24
+ FolderUp
25
+ } from "lucide-react";
26
+
27
+ // ─────────────────────────────────────────────
28
+ // Types
29
+ // ─────────────────────────────────────────────
30
+
31
+ type Size = "sm" | "md" | "lg";
32
+ type Density = "compact" | "comfortable" | "loose";
33
+
34
+ export type FileSourceKind = "native" | "path" | "url" | "custom";
35
+
36
+ export interface FileItem {
37
+ id: string;
38
+ kind: FileSourceKind;
39
+ file?: File;
40
+ path?: string;
41
+ url?: string;
42
+ name: string;
43
+ size?: number;
44
+ type?: string;
45
+ status?: "idle" | "loading" | "done" | "failed";
46
+ error?: string | null;
47
+ meta?: any;
48
+ }
49
+
50
+ export type FileLike =
51
+ | File
52
+ | string
53
+ | {
54
+ id?: string;
55
+ file?: File;
56
+ path?: string;
57
+ url?: string;
58
+ name?: string;
59
+ size?: number;
60
+ type?: string;
61
+ status?: FileItem["status"];
62
+ error?: string | null;
63
+ meta?: any;
64
+ [key: string]: unknown;
65
+ };
66
+
67
+ export type CustomFileLoaderResult = FileLike | FileLike[] | null | undefined;
68
+
69
+ export type CustomFileLoader = (ctx: {
70
+ multiple: boolean;
71
+ current: FileItem[];
72
+ }) => Promise<CustomFileLoaderResult> | CustomFileLoaderResult;
73
+
74
+ export interface ShadcnFileVariantProps
75
+ extends Pick<
76
+ VariantBaseProps<FileItem[]>,
77
+ "value" | "onValue" | "error" | "disabled" | "readOnly" | "size" | "density"
78
+ > {
79
+ multiple?: boolean;
80
+ accept?: string | string[];
81
+ maxFiles?: number;
82
+ maxTotalSize?: number;
83
+
84
+ showDropArea?: boolean;
85
+ dropIcon?: React.ReactNode;
86
+ dropTitle?: React.ReactNode;
87
+ dropDescription?: React.ReactNode;
88
+
89
+ renderDropArea?: (ctx: { openPicker: () => void; isDragging: boolean }) => React.ReactNode;
90
+ renderFileItem?: (ctx: {
91
+ item: FileItem;
92
+ index: number;
93
+ selected: boolean;
94
+ toggleSelected: () => void;
95
+ remove: () => void;
96
+ }) => React.ReactNode;
97
+
98
+ showCheckboxes?: boolean;
99
+ onFilesAdded?: (
100
+ added: FileItem[],
101
+ detail: ChangeDetail<{ from: "input" | "drop" | "custom-loader" }>
102
+ ) => void;
103
+
104
+ customLoader?: CustomFileLoader;
105
+ mergeMode?: "append" | "replace";
106
+
107
+ formatFileName?: (item: FileItem) => React.ReactNode;
108
+ formatFileSize?: (size?: number) => React.ReactNode;
109
+ placeholder?: string;
110
+
111
+ className?: string;
112
+ dropAreaClassName?: string;
113
+ listClassName?: string;
114
+
115
+ leadingIcons?: React.ReactNode[];
116
+ trailingIcons?: React.ReactNode[];
117
+ icon?: React.ReactNode;
118
+
119
+ leadingControl?: React.ReactNode;
120
+ trailingControl?: React.ReactNode;
121
+ leadingControlClassName?: string;
122
+ trailingControlClassName?: string;
123
+ joinControls?: boolean;
124
+ extendBoxToControls?: boolean;
125
+ }
126
+
127
+ // ─────────────────────────────────────────────
128
+ // Helpers
129
+ // ─────────────────────────────────────────────
130
+
131
+ function fileId() {
132
+ return `file_${Math.random().toString(36).slice(2)}`;
133
+ }
134
+
135
+ function formatSizeDefault(size?: number): string {
136
+ if (!size || size <= 0) return "—";
137
+ const kb = size / 1024;
138
+ if (kb < 1024) return `${kb.toFixed(1)} KB`;
139
+ const mb = kb / 1024;
140
+ return `${mb.toFixed(1)} MB`;
141
+ }
142
+
143
+ function sliderHeight(size?: Size) {
144
+ switch (size) {
145
+ case "sm": return "min-h-8 text-xs";
146
+ case "lg": return "min-h-12 text-base";
147
+ case "md": default: return "min-h-10 text-sm";
148
+ }
149
+ }
150
+
151
+ function toArray<T>(v: T | T[] | null | undefined): T[] {
152
+ if (v == null) return [];
153
+ return Array.isArray(v) ? v : [v];
154
+ }
155
+
156
+ function normaliseFileLike(input: FileLike): FileItem {
157
+ const asAny: any = input as any;
158
+ const existingId = asAny.id as string | undefined;
159
+
160
+ if (existingId && (asAny.file || asAny.path || asAny.url)) {
161
+ return {
162
+ id: existingId,
163
+ kind: (asAny.kind as FileSourceKind) ?? "custom",
164
+ file: asAny.file,
165
+ path: asAny.path,
166
+ url: asAny.url,
167
+ name: asAny.name ?? asAny.file?.name ?? existingId,
168
+ size: asAny.size ?? asAny.file?.size,
169
+ type: asAny.type ?? asAny.file?.type,
170
+ status: asAny.status ?? "idle",
171
+ error: asAny.error ?? null,
172
+ meta: asAny.meta,
173
+ };
174
+ }
175
+
176
+ if (input instanceof File) {
177
+ return {
178
+ id: existingId ?? fileId(),
179
+ kind: "native",
180
+ file: input,
181
+ name: input.name,
182
+ size: input.size,
183
+ type: input.type,
184
+ status: "idle",
185
+ error: null,
186
+ };
187
+ }
188
+
189
+ if (typeof input === "string") {
190
+ const isUrl = input.includes("://");
191
+ const name = input.split(/[\\/]/).pop() ?? input;
192
+ return {
193
+ id: existingId ?? fileId(),
194
+ kind: isUrl ? "url" : "path",
195
+ [isUrl ? "url" : "path"]: input,
196
+ name,
197
+ status: "idle",
198
+ error: null,
199
+ } as FileItem;
200
+ }
201
+
202
+ return {
203
+ id: existingId ?? fileId(),
204
+ kind: "custom",
205
+ name: input.name ?? "Unknown File",
206
+ status: "idle",
207
+ ...input
208
+ } as FileItem;
209
+ }
210
+
211
+ function normaliseFromFiles(list: FileList | File[]): FileItem[] {
212
+ const arr: File[] = Array.isArray(list) ? list : Array.from(list);
213
+ return arr.map(normaliseFileLike);
214
+ }
215
+
216
+ // ─────────────────────────────────────────────
217
+ // Sub-Components
218
+ // ─────────────────────────────────────────────
219
+
220
+ const FileThumbnail = ({ item }: { item: FileItem }) => {
221
+ const [preview, setPreview] = React.useState<string | null>(null);
222
+
223
+ React.useEffect(() => {
224
+ const isImage = item.type?.startsWith("image/") || item.name.match(/\.(jpg|jpeg|png|gif|webp)$/i);
225
+ if (!isImage) return;
226
+
227
+ if (item.file) {
228
+ const url = URL.createObjectURL(item.file);
229
+ setPreview(url);
230
+ return () => URL.revokeObjectURL(url);
231
+ }
232
+ if (item.url || item.path) {
233
+ setPreview(item.url || item.path || null);
234
+ }
235
+ }, [item]);
236
+
237
+ return (
238
+ <div className="relative flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-sm border bg-muted/50">
239
+ {preview ? (
240
+ <img src={preview} alt="" className="h-full w-full object-cover" />
241
+ ) : (
242
+ <FileIcon className="h-4 w-4 text-muted-foreground/50" />
243
+ )}
244
+ </div>
245
+ );
246
+ };
247
+
248
+ // ─────────────────────────────────────────────
249
+ // Main Component
250
+ // ─────────────────────────────────────────────
251
+
252
+ export const ShadcnFileVariant = React.forwardRef<HTMLDivElement, ShadcnFileVariantProps>(
253
+ function ShadcnFileVariant(props, ref) {
254
+ const {
255
+ value,
256
+ onValue,
257
+ disabled,
258
+ readOnly,
259
+ error,
260
+ size = "md",
261
+ density = "comfortable",
262
+
263
+ multiple = false,
264
+ accept,
265
+ maxFiles,
266
+ maxTotalSize,
267
+
268
+ showDropArea = false,
269
+ dropIcon,
270
+ dropTitle,
271
+ dropDescription,
272
+ renderDropArea,
273
+
274
+ renderFileItem,
275
+ showCheckboxes,
276
+ onFilesAdded,
277
+ customLoader,
278
+ mergeMode = "append",
279
+
280
+ formatFileName,
281
+ formatFileSize = formatSizeDefault,
282
+ placeholder = "Select file...",
283
+
284
+ className,
285
+ dropAreaClassName,
286
+ listClassName,
287
+
288
+ leadingIcons,
289
+ trailingIcons,
290
+ icon,
291
+ leadingControl,
292
+ trailingControl,
293
+ leadingControlClassName,
294
+ trailingControlClassName,
295
+ joinControls = true,
296
+ extendBoxToControls = true,
297
+ } = props;
298
+
299
+ // ─────────────────────────────────────────────
300
+ // State
301
+ // ─────────────────────────────────────────────
302
+ const items = value ?? [];
303
+ const isDisabled = !!disabled || !!readOnly;
304
+
305
+ const [dragOver, setDragOver] = React.useState(false);
306
+ const [selectedIds, setSelectedIds] = React.useState<Set<string>>(() => new Set());
307
+ const [popoverOpen, setPopoverOpen] = React.useState(false);
308
+ const fileInputRef = React.useRef<HTMLInputElement | null>(null);
309
+
310
+ // Pre-calculations
311
+ const heightCls = sliderHeight(size as Size);
312
+ const resolvedLeadingIcons = leadingIcons || (icon ? [icon] : []);
313
+ const resolvedTrailingIcons = trailingIcons || [];
314
+ const hasExternalControls = !!leadingControl || !!trailingControl;
315
+
316
+ const COLLAPSE_LIMIT = 2; // How many chips to show before +N
317
+
318
+ // ─────────────────────────────────────────────
319
+ // Logic
320
+ // ─────────────────────────────────────────────
321
+
322
+ const emitChange = React.useCallback(
323
+ (next: FileItem[], meta: any) => {
324
+ onValue?.(next, { source: "variant", raw: next, nativeEvent: undefined, meta });
325
+ },
326
+ [onValue]
327
+ );
328
+
329
+ const handleAddItems = (incoming: FileItem[], from: "input" | "drop" | "custom-loader") => {
330
+ if (isDisabled) return;
331
+
332
+ let next = multiple ? [...items] : [];
333
+ const added: FileItem[] = [];
334
+
335
+ for (const item of incoming) {
336
+ if (multiple && maxFiles && next.length >= maxFiles) break;
337
+
338
+ const currentTotalSize = next.reduce((acc, i) => acc + (i.size || 0), 0);
339
+ if (maxTotalSize && (currentTotalSize + (item.size || 0)) > maxTotalSize) break;
340
+
341
+ next.push(item);
342
+ added.push(item);
343
+ }
344
+
345
+ if (added.length > 0) {
346
+ onFilesAdded?.(added, { source: "variant", raw: added, nativeEvent: undefined, meta: { from } });
347
+ emitChange(next, { action: "add", from, added });
348
+ }
349
+ };
350
+
351
+ const handleRemove = (id: string) => {
352
+ const next = items.filter((i) => i.id !== id);
353
+ emitChange(next, { action: "remove", id });
354
+ if (selectedIds.has(id)) {
355
+ const nextSel = new Set(selectedIds);
356
+ nextSel.delete(id);
357
+ setSelectedIds(nextSel);
358
+ }
359
+ };
360
+
361
+ const handleBulkRemove = () => {
362
+ const next = items.filter(i => !selectedIds.has(i.id));
363
+ emitChange(next, { action: "bulk-remove", ids: Array.from(selectedIds) });
364
+ setSelectedIds(new Set());
365
+ };
366
+
367
+ const openPicker = async () => {
368
+ if (isDisabled) return;
369
+
370
+ if (customLoader) {
371
+ try {
372
+ const result = await customLoader({ multiple, current: items });
373
+ if (!result) return;
374
+
375
+ const normalized = toArray(result).map(normaliseFileLike);
376
+ if (mergeMode === "replace" || !multiple) {
377
+ emitChange(normalized, { action: "set", from: "custom-loader" });
378
+ } else {
379
+ handleAddItems(normalized, "custom-loader");
380
+ }
381
+ } catch (err) {
382
+ console.error("Custom loader failed", err);
383
+ }
384
+ return;
385
+ }
386
+ fileInputRef.current?.click();
387
+ };
388
+
389
+ const onDragOver = (e: React.DragEvent) => {
390
+ e.preventDefault();
391
+ if (!isDisabled) setDragOver(true);
392
+ };
393
+
394
+ const onDrop = (e: React.DragEvent) => {
395
+ e.preventDefault();
396
+ setDragOver(false);
397
+ if (isDisabled || !e.dataTransfer.files?.length) return;
398
+ const files = normaliseFromFiles(e.dataTransfer.files);
399
+ handleAddItems(files, "drop");
400
+ };
401
+
402
+ const onNativeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
403
+ if (e.target.files?.length) {
404
+ handleAddItems(normaliseFromFiles(e.target.files), "input");
405
+ }
406
+ e.target.value = "";
407
+ };
408
+
409
+ // ─────────────────────────────────────────────
410
+ // UI Pieces
411
+ // ─────────────────────────────────────────────
412
+
413
+ const FileChip = ({ item, condensed = false }: { item: FileItem, condensed?: boolean }) => {
414
+ const name = formatFileName ? formatFileName(item) : item.name;
415
+ return (
416
+ <div
417
+ className={cn(
418
+ "flex items-center gap-1.5 overflow-hidden rounded-sm border bg-muted/60 px-1.5 py-0.5 text-xs transition-colors hover:bg-muted",
419
+ condensed ? "max-w-[120px]" : "max-w-[200px]"
420
+ )}
421
+ onClick={(e) => e.stopPropagation()}
422
+ >
423
+ <FileIcon className="h-3 w-3 text-muted-foreground shrink-0" />
424
+ <span className="truncate font-medium">{name}</span>
425
+ <button
426
+ type="button"
427
+ onClick={(e) => { e.stopPropagation(); handleRemove(item.id); }}
428
+ className="ml-auto rounded-full text-muted-foreground/70 hover:text-destructive"
429
+ >
430
+ <X className="h-3 w-3" />
431
+ </button>
432
+ </div>
433
+ );
434
+ };
435
+
436
+ // ─────────────────────────────────────────────
437
+ // Trigger Region Logic
438
+ // ─────────────────────────────────────────────
439
+
440
+ const TriggerRegion = React.useMemo(() => {
441
+ // A. Drop Zone Mode (Big Box) - No Popover, list is external
442
+ if (showDropArea) {
443
+ if (renderDropArea) return renderDropArea({ openPicker, isDragging: dragOver });
444
+ return (
445
+ <div
446
+ onClick={openPicker}
447
+ onDragOver={onDragOver}
448
+ onDragLeave={() => setDragOver(false)}
449
+ onDrop={onDrop}
450
+ className={cn(
451
+ "group relative flex cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed px-6 py-8 text-center transition-all duration-200",
452
+ dragOver
453
+ ? "border-primary bg-primary/5 ring-4 ring-primary/10"
454
+ : "border-muted-foreground/25 hover:bg-muted/30 hover:border-muted-foreground/50",
455
+ isDisabled && "cursor-not-allowed opacity-50",
456
+ error && "border-destructive/50 bg-destructive/5",
457
+ dropAreaClassName
458
+ )}
459
+ >
460
+ <div className="rounded-full bg-background p-3 shadow-sm">
461
+ {dropIcon ?? <UploadCloud className="h-5 w-5 text-muted-foreground" />}
462
+ </div>
463
+ <div className="space-y-1">
464
+ <p className="text-sm font-medium text-foreground">{dropTitle ?? "Click or drag to select"}</p>
465
+ <p className="text-xs text-muted-foreground">{dropDescription ?? (multiple ? "Select files" : "Select a file")}</p>
466
+ </div>
467
+ </div>
468
+ );
469
+ }
470
+
471
+ // B. Select-Like Input Mode - Uses Popover
472
+ const hasItems = items.length > 0;
473
+ const visibleItems = items.slice(0, COLLAPSE_LIMIT);
474
+ const hiddenCount = items.length - COLLAPSE_LIMIT;
475
+ const isOverflowing = hiddenCount > 0;
476
+ const anySelected = selectedIds.size > 0 && showCheckboxes && multiple;
477
+
478
+ return (
479
+ <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
480
+ <PopoverTrigger asChild>
481
+ <div
482
+ className={cn(
483
+ "relative flex w-full cursor-pointer items-center gap-2 px-3 transition-all",
484
+ heightCls,
485
+ (!joinControls || !hasExternalControls) && "rounded-md border border-input bg-background shadow-xs ring-offset-background hover:bg-accent/5 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
486
+ dragOver && "border-primary ring-2 ring-primary/20",
487
+ isDisabled && "cursor-not-allowed opacity-50",
488
+ error && "border-destructive text-destructive",
489
+ className
490
+ )}
491
+ onDragOver={onDragOver}
492
+ onDragLeave={() => setDragOver(false)}
493
+ onDrop={onDrop}
494
+ >
495
+ {/* Leading Icons */}
496
+ {resolvedLeadingIcons.map((ico, i) => (
497
+ <span key={i} className="flex shrink-0 items-center justify-center text-muted-foreground">{ico}</span>
498
+ ))}
499
+
500
+ {/* Content: Chips or Placeholder */}
501
+ <div className="flex flex-1 items-center gap-2 overflow-hidden">
502
+ {hasItems ? (
503
+ <>
504
+ {visibleItems.map(item => (
505
+ <FileChip key={item.id} item={item} condensed={multiple} />
506
+ ))}
507
+ {isOverflowing && (
508
+ <span className="flex h-5 items-center justify-center rounded-sm bg-muted px-1.5 text-xs font-medium text-muted-foreground">
509
+ +{hiddenCount}
510
+ </span>
511
+ )}
512
+ </>
513
+ ) : (
514
+ <span className="truncate text-muted-foreground">{placeholder}</span>
515
+ )}
516
+ </div>
517
+
518
+ {/* Trailing Icons & Controls */}
519
+ {resolvedTrailingIcons.map((ico, i) => (
520
+ <span key={i} className="flex shrink-0 items-center justify-center text-muted-foreground">{ico}</span>
521
+ ))}
522
+
523
+ {/* 1. Dedicated File Control Button (Folder Icon) */}
524
+ <Button
525
+ type="button"
526
+ variant="ghost"
527
+ size="icon"
528
+ className="h-6 w-6 shrink-0 text-muted-foreground hover:text-foreground"
529
+ onClick={(e) => {
530
+ e.stopPropagation(); // Stop Popover from toggling
531
+ openPicker();
532
+ }}
533
+ >
534
+ <FolderUp className="h-4 w-4" />
535
+ </Button>
536
+
537
+ {/* 2. Chevron (for Popover) */}
538
+ <ChevronDown className={cn("h-4 w-4 shrink-0 text-muted-foreground opacity-50 transition-transform duration-200", popoverOpen && "rotate-180")} />
539
+ </div>
540
+ </PopoverTrigger>
541
+
542
+ {/* Popover Content (Full Width) */}
543
+ <PopoverContent
544
+ className="w-[--radix-popover-trigger-width] p-0"
545
+ align="start"
546
+ >
547
+ <div className="flex flex-col">
548
+ {/* Header: Mass Selection Actions OR Summary */}
549
+ <div className="flex items-center justify-between border-b px-3 py-2 text-xs font-medium text-muted-foreground">
550
+ <span>
551
+ {anySelected ? `${selectedIds.size} selected` : `${items.length} files total`}
552
+ </span>
553
+
554
+ {anySelected ? (
555
+ <button
556
+ type="button"
557
+ className="text-destructive hover:underline"
558
+ onClick={handleBulkRemove}
559
+ >
560
+ Remove selected
561
+ </button>
562
+ ) : items.length > 0 && (
563
+ <button
564
+ type="button"
565
+ className="text-muted-foreground hover:text-foreground"
566
+ onClick={() => emitChange([], { action: "clear" })}
567
+ >
568
+ Clear all
569
+ </button>
570
+ )}
571
+ </div>
572
+
573
+ {/* Scrollable List with Checkboxes */}
574
+ <ScrollArea className="h-auto max-h-[300px] w-full p-1">
575
+ <div className="flex flex-col gap-1">
576
+ {items.map(item => {
577
+ const selected = selectedIds.has(item.id);
578
+ const toggle = () => {
579
+ const next = new Set(selectedIds);
580
+ if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
581
+ setSelectedIds(next);
582
+ };
583
+ return (
584
+ <div key={item.id} className="group flex items-center gap-3 rounded-md px-2 py-2 text-sm transition-colors hover:bg-muted/50">
585
+ {showCheckboxes && multiple && (
586
+ <Checkbox checked={selected} onCheckedChange={toggle} className="h-4 w-4 shrink-0" />
587
+ )}
588
+
589
+ <FileThumbnail item={item} />
590
+
591
+ <div className="min-w-0 flex-1">
592
+ <div className="truncate font-medium">{formatFileName?.(item) ?? item.name}</div>
593
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
594
+ <span>{formatFileSize(item.size)}</span>
595
+ {item.status === 'failed' && <span className="text-destructive">Failed</span>}
596
+ </div>
597
+ </div>
598
+
599
+ <Button
600
+ variant="ghost"
601
+ size="icon"
602
+ className="h-7 w-7 opacity-0 group-hover:opacity-100"
603
+ onClick={(e) => { e.stopPropagation(); handleRemove(item.id); }}
604
+ >
605
+ <Trash2 className="h-3.5 w-3.5 text-muted-foreground" />
606
+ </Button>
607
+ </div>
608
+ );
609
+ })}
610
+ {items.length === 0 && (
611
+ <div className="py-4 text-center text-xs text-muted-foreground">
612
+ No files selected
613
+ </div>
614
+ )}
615
+ </div>
616
+ </ScrollArea>
617
+
618
+ {/* Footer Add Button */}
619
+ <div className="border-t p-1">
620
+ <Button
621
+ variant="secondary"
622
+ size="sm"
623
+ className="w-full justify-start text-xs"
624
+ onClick={() => { setPopoverOpen(false); openPicker(); }}
625
+ >
626
+ <Plus className="mr-2 h-3 w-3" />
627
+ Add files...
628
+ </Button>
629
+ </div>
630
+ </div>
631
+ </PopoverContent>
632
+ </Popover>
633
+ );
634
+ }, [
635
+ showDropArea, items, multiple, dragOver, isDisabled, placeholder,
636
+ joinControls, hasExternalControls, resolvedLeadingIcons, resolvedTrailingIcons,
637
+ popoverOpen, COLLAPSE_LIMIT, heightCls, openPicker, onDragOver, onDrop, renderDropArea, className,
638
+ error, dropAreaClassName, dropIcon, dropTitle, dropDescription,
639
+ selectedIds, showCheckboxes, handleBulkRemove, emitChange, formatFileName, formatFileSize, handleRemove
640
+ ]);
641
+
642
+ // ─────────────────────────────────────────────
643
+ // External List (Drop Zone Mode Only)
644
+ // ─────────────────────────────────────────────
645
+
646
+ const showExternalList = (multiple && showDropArea && items.length > 0);
647
+ const anySelectedExternal = selectedIds.size > 0 && showCheckboxes && multiple;
648
+
649
+ const ExternalFileList = showExternalList ? (
650
+ <>
651
+ {/* Bulk Actions for External List */}
652
+ {(anySelectedExternal || items.length > 0) && (
653
+ <div className="mt-2 flex items-center justify-between px-1 text-xs text-muted-foreground">
654
+ <span>{items.length} files</span>
655
+ <div className="flex gap-2">
656
+ {anySelectedExternal && (
657
+ <button type="button" onClick={handleBulkRemove} className="text-destructive hover:underline">Remove selected</button>
658
+ )}
659
+ <button type="button" onClick={() => emitChange([], { action: "clear" })} className="hover:text-foreground">Clear all</button>
660
+ </div>
661
+ </div>
662
+ )}
663
+
664
+ <ScrollArea className={cn("mt-1 w-full", listClassName)}>
665
+ <div className="flex flex-col gap-2">
666
+ {items.map((item, index) => {
667
+ const selected = selectedIds.has(item.id);
668
+ const toggle = () => {
669
+ const next = new Set(selectedIds);
670
+ if (next.has(item.id)) next.delete(item.id); else next.add(item.id);
671
+ setSelectedIds(next);
672
+ };
673
+
674
+ if (renderFileItem) {
675
+ return renderFileItem({ item, index, selected, toggleSelected: toggle, remove: () => handleRemove(item.id) });
676
+ }
677
+
678
+ return (
679
+ <div key={item.id} className="group relative flex items-center gap-3 rounded-lg border bg-card p-2 pr-3 transition-all hover:bg-muted/30">
680
+ {showCheckboxes && <Checkbox checked={selected} onCheckedChange={toggle} className="ml-1" />}
681
+ <FileThumbnail item={item} />
682
+ <div className="min-w-0 flex-1 space-y-1">
683
+ <div className="flex items-center justify-between gap-2">
684
+ <span className="truncate text-sm font-medium text-foreground">{formatFileName?.(item) ?? item.name}</span>
685
+ </div>
686
+ <div className="flex items-center gap-2 text-xs text-muted-foreground">
687
+ <span>{formatFileSize(item.size)}</span>
688
+ {item.status === 'loading' && <span className="flex items-center gap-1 text-primary"><Loader2 className="h-3 w-3 animate-spin" /></span>}
689
+ {item.status === 'failed' && <span className="flex items-center gap-1 text-destructive"><AlertCircle className="h-3 w-3" /></span>}
690
+ {item.status === 'done' && <CheckCircle2 className="h-3 w-3 text-emerald-500" />}
691
+ </div>
692
+ </div>
693
+ <button type="button" onClick={() => handleRemove(item.id)} className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-muted-foreground/70 opacity-0 transition-all hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100">
694
+ <Trash2 className="h-4 w-4" />
695
+ </button>
696
+ </div>
697
+ );
698
+ })}
699
+ </div>
700
+ </ScrollArea>
701
+ </>
702
+ ) : null;
703
+
704
+ // ─────────────────────────────────────────────
705
+ // Render
706
+ // ─────────────────────────────────────────────
707
+
708
+ return (
709
+ <div
710
+ ref={ref}
711
+ className={cn("w-full", className)}
712
+ aria-disabled={isDisabled}
713
+ aria-invalid={!!error}
714
+ >
715
+ {/* 1. Trigger Group */}
716
+ <div className={cn(
717
+ "flex w-full",
718
+ joinControls && extendBoxToControls && !showDropArea
719
+ ? "items-stretch rounded-md border border-input bg-background shadow-xs ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
720
+ : "items-start gap-2"
721
+ )}>
722
+ {leadingControl && (
723
+ <div className={cn(
724
+ "flex items-center",
725
+ joinControls && !showDropArea && "border-r bg-muted/50 px-3",
726
+ leadingControlClassName
727
+ )}>
728
+ {leadingControl}
729
+ </div>
730
+ )}
731
+
732
+ <div className="flex-1 min-w-0">
733
+ {TriggerRegion}
734
+ </div>
735
+
736
+ {trailingControl && (
737
+ <div className={cn(
738
+ "flex items-center",
739
+ joinControls && !showDropArea && "border-l bg-muted/50 px-3",
740
+ trailingControlClassName
741
+ )}>
742
+ {trailingControl}
743
+ </div>
744
+ )}
745
+ </div>
746
+
747
+ {/* 2. External List (Drop Zone Mode Only) */}
748
+ {ExternalFileList}
749
+
750
+ <input
751
+ ref={fileInputRef}
752
+ type="file"
753
+ className="hidden"
754
+ multiple={multiple}
755
+ accept={Array.isArray(accept) ? accept.join(",") : accept}
756
+ onChange={onNativeChange}
757
+ />
758
+ </div>
759
+ );
760
+ }
761
+ );
762
+
763
+ ShadcnFileVariant.displayName = "ShadcnFileVariant";
764
+ export default ShadcnFileVariant;