@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.
- package/.scaffold-cache.json +537 -0
- package/package.json +42 -0
- package/src/.scaffold-cache.json +544 -0
- package/src/adapters/axios.ts +117 -0
- package/src/adapters/index.ts +91 -0
- package/src/adapters/inertia.ts +187 -0
- package/src/core/adapter-registry.ts +87 -0
- package/src/core/bound/bind-host.ts +14 -0
- package/src/core/bound/observe-bound-field.ts +172 -0
- package/src/core/bound/wait-for-bound-field.ts +57 -0
- package/src/core/context.ts +23 -0
- package/src/core/core-provider.tsx +818 -0
- package/src/core/core-root.tsx +72 -0
- package/src/core/core-shell.tsx +44 -0
- package/src/core/errors/error-strip.tsx +71 -0
- package/src/core/errors/index.ts +2 -0
- package/src/core/errors/map-error-bag.ts +51 -0
- package/src/core/errors/map-zod.ts +39 -0
- package/src/core/hooks/use-button.ts +220 -0
- package/src/core/hooks/use-core-context.ts +20 -0
- package/src/core/hooks/use-core-utility.ts +0 -0
- package/src/core/hooks/use-core.ts +13 -0
- package/src/core/hooks/use-field.ts +497 -0
- package/src/core/hooks/use-optional-field.ts +28 -0
- package/src/core/index.ts +0 -0
- package/src/core/registry/binder-registry.ts +82 -0
- package/src/core/registry/field-registry.ts +187 -0
- package/src/core/test.tsx +17 -0
- package/src/global.d.ts +14 -0
- package/src/index.ts +68 -0
- package/src/input/index.ts +4 -0
- package/src/input/input-field.tsx +854 -0
- package/src/input/input-layout-graph.ts +230 -0
- package/src/input/input-props.ts +190 -0
- package/src/lib/get-global-countries.ts +87 -0
- package/src/lib/utils.ts +6 -0
- package/src/presets/index.ts +0 -0
- package/src/presets/shadcn-preset.ts +0 -0
- package/src/presets/shadcn-variants/checkbox.tsx +849 -0
- package/src/presets/shadcn-variants/chips.tsx +756 -0
- package/src/presets/shadcn-variants/color.tsx +284 -0
- package/src/presets/shadcn-variants/custom.tsx +227 -0
- package/src/presets/shadcn-variants/date.tsx +796 -0
- package/src/presets/shadcn-variants/file.tsx +764 -0
- package/src/presets/shadcn-variants/keyvalue.tsx +556 -0
- package/src/presets/shadcn-variants/multiselect.tsx +1132 -0
- package/src/presets/shadcn-variants/number.tsx +176 -0
- package/src/presets/shadcn-variants/password.tsx +737 -0
- package/src/presets/shadcn-variants/phone.tsx +628 -0
- package/src/presets/shadcn-variants/radio.tsx +578 -0
- package/src/presets/shadcn-variants/select.tsx +956 -0
- package/src/presets/shadcn-variants/slider.tsx +622 -0
- package/src/presets/shadcn-variants/text.tsx +343 -0
- package/src/presets/shadcn-variants/textarea.tsx +66 -0
- package/src/presets/shadcn-variants/toggle.tsx +218 -0
- package/src/presets/shadcn-variants/treeselect.tsx +784 -0
- package/src/presets/ui/badge.tsx +46 -0
- package/src/presets/ui/button.tsx +60 -0
- package/src/presets/ui/calendar.tsx +214 -0
- package/src/presets/ui/checkbox.tsx +115 -0
- package/src/presets/ui/custom.tsx +0 -0
- package/src/presets/ui/dialog.tsx +141 -0
- package/src/presets/ui/field.tsx +246 -0
- package/src/presets/ui/input-mask.tsx +739 -0
- package/src/presets/ui/input-otp.tsx +77 -0
- package/src/presets/ui/input.tsx +1011 -0
- package/src/presets/ui/label.tsx +22 -0
- package/src/presets/ui/number.tsx +1370 -0
- package/src/presets/ui/popover.tsx +46 -0
- package/src/presets/ui/radio-group.tsx +43 -0
- package/src/presets/ui/scroll-area.tsx +56 -0
- package/src/presets/ui/select.tsx +190 -0
- package/src/presets/ui/separator.tsx +28 -0
- package/src/presets/ui/slider.tsx +61 -0
- package/src/presets/ui/switch.tsx +32 -0
- package/src/presets/ui/textarea.tsx +634 -0
- package/src/presets/ui/time-dropdowns.tsx +350 -0
- package/src/schema/adapter.ts +217 -0
- package/src/schema/core.ts +429 -0
- package/src/schema/field-map.ts +0 -0
- package/src/schema/field.ts +224 -0
- package/src/schema/index.ts +0 -0
- package/src/schema/input-field.ts +260 -0
- package/src/schema/presets.ts +0 -0
- package/src/schema/variant.ts +216 -0
- package/src/variants/core/checkbox.tsx +54 -0
- package/src/variants/core/chips.tsx +22 -0
- package/src/variants/core/color.tsx +16 -0
- package/src/variants/core/custom.tsx +18 -0
- package/src/variants/core/date.tsx +25 -0
- package/src/variants/core/file.tsx +9 -0
- package/src/variants/core/keyvalue.tsx +12 -0
- package/src/variants/core/multiselect.tsx +28 -0
- package/src/variants/core/number.tsx +115 -0
- package/src/variants/core/password.tsx +35 -0
- package/src/variants/core/phone.tsx +16 -0
- package/src/variants/core/radio.tsx +38 -0
- package/src/variants/core/select.tsx +15 -0
- package/src/variants/core/slider.tsx +55 -0
- package/src/variants/core/text.tsx +114 -0
- package/src/variants/core/textarea.tsx +22 -0
- package/src/variants/core/toggle.tsx +50 -0
- package/src/variants/core/treeselect.tsx +11 -0
- package/src/variants/helpers/selection-summary.tsx +236 -0
- package/src/variants/index.ts +75 -0
- package/src/variants/registry.ts +38 -0
- package/src/variants/select-shared.ts +0 -0
- package/src/variants/shared.ts +126 -0
- 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;
|