{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"file-upload","type":"registry:ui","title":"FileUpload","description":"A drop zone backed by a native file input, with per-file progress, JS-side validation and a removable file list","author":"fancy-ui <https://fancy-ui.rama.app>","categories":["forms"],"registryDependencies":["https://fancy-ui.rama.app/r/internals.json","https://fancy-ui.rama.app/r/internals-dom.json","https://fancy-ui.rama.app/r/internals-motion.json","https://fancy-ui.rama.app/r/sound.json","https://fancy-ui.rama.app/r/utils.json"],"files":[{"path":"react/src/components/file-upload/FileUpload.tsx","content":"\"use client\";\n\nimport { forwardRef, useRef, useState } from \"react\";\nimport type {\n\tChangeEvent,\n\tComponentPropsWithoutRef,\n\tCSSProperties,\n\tDragEvent,\n\tMouseEvent,\n\tRefCallback,\n} from \"react\";\nimport { cn } from \"../../utils\";\nimport { useField } from \"../../internals/field\";\nimport { useFancyId } from \"../../internals/use-id\";\nimport { useComposedRefs } from \"../../internals/dom/use-composed-refs\";\nimport { useIsomorphicLayoutEffect } from \"../../internals/dom/ssr\";\nimport { preset } from \"../../internals/motion/transitions\";\nimport { prefersReducedMotion } from \"../../internals/motion/anchored\";\nimport { DURATIONS, JS_EASINGS } from \"../../internals/motion/tokens\";\nimport { runTransition, type TransitionRun } from \"../../internals/motion/animate\";\nimport { useSoundCue } from \"../../sound/use-sound\";\nimport type { SoundCue } from \"../../sound/types\";\nimport \"./file-upload.css\";\n\n/** One file FileUpload is tracking — selected, uploading, or settled. */\nexport interface UploadFile {\n\t/** Stable id for this row. Used as the row key — never the array index, since rows are removed from the middle. */\n\tid: string;\n\t/** The underlying browser File. */\n\tfile: File;\n\t/** 0–100, or null when the consumer is not reporting progress. */\n\tprogress: number | null;\n\t/** Lifecycle state. \"error\" covers both local validation failures (accept/maxSize/maxFiles) and upload failures the consumer reports back. */\n\tstatus: \"pending\" | \"uploading\" | \"done\" | \"error\";\n\t/** Message shown on the row and announced while status is \"error\". */\n\terror?: string;\n}\n\ninterface FileUploadOwnProps {\n\t/**\n\t * Selected files. Controlled when supplied: pair it with `onFilesChange`,\n\t * the React counterpart of the Svelte source's `bind:files`.\n\t */\n\tfiles?: UploadFile[];\n\t/** Initial files when uncontrolled (no `files` prop given). */\n\tdefaultFiles?: UploadFile[];\n\t/** Called with the new list on every change — a selection, a drop, or a removal. */\n\tonFilesChange?: (files: UploadFile[]) => void;\n\t/** The input's native `accept` attribute (comma-separated extensions and/or MIME types/wildcards). Also enforced in JS: a dropped file never passes through the picker, so the browser's own filtering never sees it. */\n\taccept?: string;\n\t/** Allows more than one file per selection or drop. */\n\tmultiple?: boolean;\n\t/** Maximum size per file, in bytes. A larger file is added to the list with status \"error\" rather than silently discarded. */\n\tmaxSize?: number;\n\t/** Maximum number of files the list may hold. Files beyond the cap are rejected outright, not added as errors. */\n\tmaxFiles?: number;\n\t/** Blocks selecting, dropping and removing files. Overridden by a surrounding FormField. */\n\tdisabled?: boolean;\n\t/** Native `required` on the underlying input. Overridden by a surrounding FormField. */\n\trequired?: boolean;\n\t/** Drives the error border and `aria-invalid`. Overridden by a surrounding FormField. */\n\tinvalid?: boolean;\n\t/** Element id, applied to the underlying file input. Overridden by a surrounding FormField's own `controlId`. */\n\tid?: string;\n\t/** Native `name` on the underlying input. */\n\tname?: string;\n\t/** Accessible name — for a control with no visible Label next to it. Omit when a surrounding FormField already supplies one. */\n\tlabel?: string;\n\t/** Constraint text under the drop zone, e.g. \"PNG, SVG — 4 MB max\". Informational only; not itself enforced — pair it with matching `accept`/`maxSize` values. */\n\thint?: string;\n\t/** Additional CSS classes. */\n\tclassName?: string;\n\t/**\n\t * Plays the matching interface cue through the sound controller. Off by\n\t * default; only audible once the user has enabled sound.\n\t */\n\tsound?: boolean;\n}\n\nexport interface FileUploadProps\n\textends\n\t\tOmit<\n\t\t\tComponentPropsWithoutRef<\"input\">,\n\t\t\tkeyof FileUploadOwnProps | \"type\" | \"value\" | \"defaultValue\" | \"role\"\n\t\t>,\n\t\tFileUploadOwnProps {}\n\n// A split enter/exit pair rather than one bidirectional transition, which is\n// the house rule everywhere else. The list is keyed, and an id this component\n// mints is never reused (`createRowId` only counts up), so the reversal\n// smoothing a bidirectional transition buys is smoothing nothing. A controlled\n// consumer that supplies its own ids CAN bring one back mid-exit; the\n// reconciler revives that row in place rather than reversing a leg for it.\n// Splitting also lets the exit be its own, quieter gesture\n// rather than the entrance played backwards: a row arrives by rising into\n// place and leaves by simply fading, so nothing appears to travel back out of\n// the list.\nconst rowEnter = preset(\"fade-up\");\nconst rowLeave = preset(\"fade\");\n\n/** One list row as actually rendered — the live entry, or a removed entry kept\n *  mounted (and inert) while its exit transition plays out. */\ninterface RenderedRow {\n\tentry: UploadFile;\n\texiting: boolean;\n}\n\nfunction sameRendered(a: RenderedRow[], b: RenderedRow[]): boolean {\n\tif (a.length !== b.length) return false;\n\tfor (let i = 0; i < a.length; i += 1) {\n\t\tif (a[i]!.entry !== b[i]!.entry || a[i]!.exiting !== b[i]!.exiting) return false;\n\t}\n\treturn true;\n}\n\nfunction formatBytes(bytes: number): string {\n\tif (bytes >= 1_000_000) {\n\t\tconst mb = bytes / 1_000_000;\n\t\treturn `${mb % 1 === 0 ? mb.toFixed(0) : mb.toFixed(1)} MB`;\n\t}\n\tif (bytes >= 1_000) {\n\t\tconst kb = bytes / 1_000;\n\t\treturn `${kb % 1 === 0 ? kb.toFixed(0) : kb.toFixed(1)} KB`;\n\t}\n\treturn `${bytes} B`;\n}\n\n// The input's own `accept` attribute only filters what the OS picker\n// shows — it is a convenience, not a guarantee, and a dropped file\n// bypasses it entirely. This is the real gate, run for both paths.\nfunction matchesAccept(file: File, pattern: string | undefined): boolean {\n\tif (!pattern) return true;\n\tconst rules = pattern\n\t\t.split(\",\")\n\t\t.map((rule) => rule.trim().toLowerCase())\n\t\t.filter(Boolean);\n\tif (rules.length === 0) return true;\n\n\tconst fileName = file.name.toLowerCase();\n\tconst fileType = file.type.toLowerCase();\n\treturn rules.some((rule) => {\n\t\tif (rule.startsWith(\".\")) return fileName.endsWith(rule);\n\t\tif (rule.endsWith(\"/*\")) return fileType.startsWith(rule.slice(0, -1));\n\t\treturn fileType === rule;\n\t});\n}\n\n/**\n * A drop zone wrapped around a real, focusable `<input type=\"file\">`, with a\n * validated file list underneath — accept/maxSize/maxFiles enforced identically\n * for the picker and the drop path, rejections announced through a live region,\n * and rows that enter and leave through the shared motion presets.\n *\n * The element reference arrives through the ref channel (the Svelte source\n * declares `ref = $bindable(null)`), pointing at the native file input.\n */\nexport const FileUpload = forwardRef<HTMLInputElement, FileUploadProps>(\n\t(\n\t\t{\n\t\t\tfiles: filesProp,\n\t\t\tdefaultFiles = [],\n\t\t\tonFilesChange,\n\t\t\taccept,\n\t\t\tmultiple = false,\n\t\t\tmaxSize,\n\t\t\tmaxFiles,\n\t\t\tdisabled = false,\n\t\t\trequired = false,\n\t\t\tinvalid = false,\n\t\t\tid,\n\t\t\tname,\n\t\t\tlabel,\n\t\t\thint,\n\t\t\tclassName,\n\t\t\tsound = false,\n\t\t\tonChange,\n\t\t\tonClick,\n\t\t\t\"aria-label\": ariaLabel,\n\t\t\t\"aria-describedby\": ariaDescribedby,\n\t\t\t\"aria-invalid\": ariaInvalid,\n\t\t\t// An `<input>` is a void element: a stray `children` must not reach it.\n\t\t\tchildren: _children,\n\t\t\t...rest\n\t\t},\n\t\tref\n\t) => {\n\t\t// The Svelte side's `files = $bindable([])` becomes the standard\n\t\t// controlled/uncontrolled split: uncontrolled by default, controlled\n\t\t// the moment a `files` prop is passed.\n\t\tconst isControlled = filesProp !== undefined;\n\t\tconst [uncontrolledFiles, setUncontrolledFiles] = useState<UploadFile[]>(defaultFiles);\n\t\tconst files = isControlled ? filesProp : uncontrolledFiles;\n\n\t\t// Undefined outside a FormField — every fallback below then uses this\n\t\t// component's own prop instead, so the control works standalone\n\t\t// exactly as it does wrapped. Context always wins when present, via `??`.\n\t\tconst field = useField();\n\n\t\t// The file input is a labelable element, so — unlike a control whose root\n\t\t// isn't (a role=\"radiogroup\" wrapper needs labelId instead) — a plain\n\t\t// controlId + <label for> pairing is enough. Generated with useFancyId(),\n\t\t// not uid(), because that one throws outside the browser and this\n\t\t// control's own \"browse\" label needs a stable target id even during SSR,\n\t\t// FormField or not.\n\t\tconst generatedId = useFancyId();\n\t\tconst effectiveId = field?.controlId ?? id ?? generatedId;\n\t\tconst effectiveDisabled = field?.disabled ?? disabled;\n\t\tconst effectiveRequired = field?.required ?? required;\n\t\tconst effectiveInvalid = field?.invalid ?? invalid;\n\n\t\tconst playCue = useSoundCue(sound);\n\n\t\tconst hintId = `${effectiveId}-hint`;\n\t\tconst describedBy =\n\t\t\t[field?.describedBy, ariaDescribedby, hint ? hintId : undefined].filter(Boolean).join(\" \") ||\n\t\t\tundefined;\n\n\t\t// Counts enter/leave pairs instead of toggling a boolean on either one.\n\t\t// dragenter/dragleave bubble from every child the pointer crosses — the\n\t\t// icon, the prompt text, the hint — so a naive \"enter -> true, leave ->\n\t\t// false\" flickers false the instant the pointer passes over any of them.\n\t\t// The zone reads as dragging exactly while this count is above zero,\n\t\t// which stays true across a leave that is immediately followed by the\n\t\t// matching child's enter.\n\t\tconst [dragDepth, setDragDepth] = useState(0);\n\t\tconst dragging = dragDepth > 0;\n\n\t\tconst inputRef = useRef<HTMLInputElement | null>(null);\n\t\tconst composedInputRef = useComposedRefs(ref, inputRef);\n\t\tconst listRef = useRef<HTMLUListElement | null>(null);\n\n\t\t// Rejections are announced here rather than left to the per-row error\n\t\t// text alone — that text sits in the DOM but nothing moves focus to it,\n\t\t// so a screen reader only reaches it if this also speaks.\n\t\tconst [liveMessage, setLiveMessage] = useState(\"\");\n\n\t\t// A live region only speaks when its text actually CHANGES. Rejecting\n\t\t// the very same file twice produces byte-identical text, so writing it\n\t\t// straight back would leave the text node untouched and the second\n\t\t// rejection silent. Clearing first and writing the message one commit\n\t\t// later forces the mutation an assistive technology listens for.\n\t\tfunction announce(message: string) {\n\t\t\tsetLiveMessage(\"\");\n\t\t\tqueueMicrotask(() => setLiveMessage(message));\n\t\t}\n\n\t\tconst nextRowIdRef = useRef(0);\n\t\t// Minted inside event handlers only — never in a render path — so the\n\t\t// counter can be a plain ref without breaking hydration.\n\t\tfunction createRowId(): string {\n\t\t\tnextRowIdRef.current += 1;\n\t\t\treturn `${generatedId}-file-${nextRowIdRef.current}`;\n\t\t}\n\n\t\t// ------------------------------------------------------------------\n\t\t// Exit-aware rendering. The Svelte source hands a removed row's exit to\n\t\t// the framework's outro scheduler (`out:` + `|global` so even the LAST\n\t\t// row keeps the <ul> mounted until it finishes). React removes what\n\t\t// stops rendering in the same commit, so the clock is owned here: the\n\t\t// rendered list is `files` plus every removed row still playing its\n\t\t// exit, each kept at its old position, marked `inert` for the whole of\n\t\t// it, and dropped only when the transition lands.\n\t\t// ------------------------------------------------------------------\n\t\tconst [rendered, setRendered] = useState<RenderedRow[]>(() =>\n\t\t\tfiles.map((entry) => ({ entry, exiting: false }))\n\t\t);\n\t\tconst renderedRef = useRef(rendered);\n\t\tconst rowElsRef = useRef(new Map<string, HTMLLIElement>());\n\t\tconst rowRefCallbacksRef = useRef(new Map<string, RefCallback<HTMLLIElement>>());\n\t\tconst runsRef = useRef(new Map<string, TransitionRun>());\n\t\tconst exitStartedRef = useRef(new Set<string>());\n\t\tconst enterIdsRef = useRef(new Set<string>());\n\n\t\tfunction rowRefFor(rowId: string): RefCallback<HTMLLIElement> {\n\t\t\tlet callback = rowRefCallbacksRef.current.get(rowId);\n\t\t\tif (!callback) {\n\t\t\t\tcallback = (node) => {\n\t\t\t\t\tif (node) {\n\t\t\t\t\t\trowElsRef.current.set(rowId, node);\n\t\t\t\t\t} else {\n\t\t\t\t\t\trowElsRef.current.delete(rowId);\n\t\t\t\t\t\t// Evicted by identity: a detach runs on the OLD\n\t\t\t\t\t\t// callback, and by then the cache may already hold its\n\t\t\t\t\t\t// replacement. Deleting unconditionally would throw\n\t\t\t\t\t\t// that replacement away and mint a new callback on\n\t\t\t\t\t\t// every render for the rest of the component's life,\n\t\t\t\t\t\t// making React detach and re-attach every row's ref\n\t\t\t\t\t\t// each time — the exact churn this cache exists to\n\t\t\t\t\t\t// prevent.\n\t\t\t\t\t\tif (rowRefCallbacksRef.current.get(rowId) === callback) {\n\t\t\t\t\t\t\trowRefCallbacksRef.current.delete(rowId);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t};\n\t\t\t\trowRefCallbacksRef.current.set(rowId, callback);\n\t\t\t}\n\t\t\treturn callback;\n\t\t}\n\n\t\t// `cue` is played between the commit and the consumer callback, the\n\t\t// same slot the Svelte source plays it in.\n\t\tfunction commitFiles(next: UploadFile[], cue?: SoundCue) {\n\t\t\tif (!isControlled) setUncontrolledFiles(next);\n\t\t\tif (cue) playCue(cue);\n\t\t\tonFilesChange?.(next);\n\t\t}\n\n\t\t// Reconcile the rendered list against `files`: a row that vanished from\n\t\t// `files` stays, flagged as exiting; a row that appeared is inserted in\n\t\t// order and — only when the list already existed — queued for an\n\t\t// entrance. That guard mirrors the Svelte side's LOCAL `in:`: a row\n\t\t// present on first render, or created because the whole list block just\n\t\t// appeared, paints at rest with no intro.\n\t\tuseIsomorphicLayoutEffect(() => {\n\t\t\tconst prev = renderedRef.current;\n\t\t\tconst listExisted = prev.some((row) => !row.exiting);\n\t\t\tconst fileIds = new Set(files.map((entry) => entry.id));\n\t\t\tconst next: RenderedRow[] = [];\n\t\t\tlet fileIndex = 0;\n\t\t\t// Ids whose row was mid-exit when the same id came back.\n\t\t\tconst revived: string[] = [];\n\n\t\t\tconst pushFresh = (entry: UploadFile) => {\n\t\t\t\tnext.push({ entry, exiting: false });\n\t\t\t\tif (listExisted) enterIdsRef.current.add(entry.id);\n\t\t\t};\n\n\t\t\tfor (const row of prev) {\n\t\t\t\tif (fileIds.has(row.entry.id)) {\n\t\t\t\t\twhile (fileIndex < files.length && files[fileIndex]!.id !== row.entry.id) {\n\t\t\t\t\t\tpushFresh(files[fileIndex]!);\n\t\t\t\t\t\tfileIndex += 1;\n\t\t\t\t\t}\n\t\t\t\t\t// A row still playing its exit whose id is back in `files`\n\t\t\t\t\t// is revived IN PLACE, never re-added alongside itself: two\n\t\t\t\t\t// rows under one key would share a single ref callback, and\n\t\t\t\t\t// the id filter in `finishExit` would take both away when\n\t\t\t\t\t// the exit landed — losing the entry the consumer had just\n\t\t\t\t\t// put back. The keyed block on the Svelte side resumes the\n\t\t\t\t\t// leaving element for a returning key for the same reason.\n\t\t\t\t\tif (row.exiting) revived.push(row.entry.id);\n\t\t\t\t\t// The `fileIds.has` guard above guarantees this index lands on\n\t\t\t\t\t// the matching entry.\n\t\t\t\t\tnext.push({ entry: files[fileIndex]!, exiting: false });\n\t\t\t\t\tfileIndex += 1;\n\t\t\t\t} else {\n\t\t\t\t\tnext.push({ entry: row.entry, exiting: true });\n\t\t\t\t}\n\t\t\t}\n\t\t\twhile (fileIndex < files.length) {\n\t\t\t\tpushFresh(files[fileIndex]!);\n\t\t\t\tfileIndex += 1;\n\t\t\t}\n\n\t\t\tfor (const rowId of revived) {\n\t\t\t\trunsRef.current.get(rowId)?.abort();\n\t\t\t\trunsRef.current.delete(rowId);\n\t\t\t\texitStartedRef.current.delete(rowId);\n\t\t\t\trowElsRef.current.get(rowId)?.removeAttribute(\"inert\");\n\t\t\t}\n\n\t\t\tif (!sameRendered(prev, next)) {\n\t\t\t\trenderedRef.current = next;\n\t\t\t\tsetRendered(next);\n\t\t\t}\n\t\t}, [files]);\n\n\t\t// Start the legs the reconciliation above queued up. Layout phase, so a\n\t\t// reduced-motion exit (duration 0 → synchronous finish, `animate()`\n\t\t// never called) settles before paint, exactly like the source's fast\n\t\t// path — a visitor who asked for less motion gets a synchronous removal.\n\t\tuseIsomorphicLayoutEffect(() => {\n\t\t\tconst finishExit = (rowId: string) => {\n\t\t\t\trunsRef.current.delete(rowId);\n\t\t\t\texitStartedRef.current.delete(rowId);\n\t\t\t\trenderedRef.current = renderedRef.current.filter((row) => row.entry.id !== rowId);\n\t\t\t\tsetRendered(renderedRef.current);\n\t\t\t};\n\n\t\t\tfor (const row of renderedRef.current) {\n\t\t\t\tconst rowId = row.entry.id;\n\t\t\t\tif (!row.exiting || exitStartedRef.current.has(rowId)) continue;\n\t\t\t\texitStartedRef.current.add(rowId);\n\t\t\t\tconst element = rowElsRef.current.get(rowId);\n\t\t\t\tif (!element) {\n\t\t\t\t\tfinishExit(rowId);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t\t// Set synchronously, immediately before the exit starts — the\n\t\t\t\t// same instant the Svelte outro marks a leaving element inert.\n\t\t\t\t// A closing row is not something a pointer or a screen reader\n\t\t\t\t// should be able to reach.\n\t\t\t\telement.toggleAttribute(\"inert\", true);\n\t\t\t\tconst spec = rowLeave(\n\t\t\t\t\telement,\n\t\t\t\t\t{\n\t\t\t\t\t\tduration: prefersReducedMotion() ? 0 : DURATIONS.exit,\n\t\t\t\t\t\teasing: JS_EASINGS.in,\n\t\t\t\t\t},\n\t\t\t\t\t{ direction: \"out\" }\n\t\t\t\t);\n\t\t\t\tconst run = runTransition(element, spec, 0, runsRef.current.get(rowId), () =>\n\t\t\t\t\tfinishExit(rowId)\n\t\t\t\t);\n\t\t\t\t// Registered only while the leg is still live. On the\n\t\t\t\t// reduced-motion path `runTransition` finishes SYNCHRONOUSLY,\n\t\t\t\t// so `finishExit` has already dropped this row from the map by\n\t\t\t\t// the time we get here — writing the spent handle back would\n\t\t\t\t// leave a dead entry behind for good, and hand it to the next\n\t\t\t\t// exit of the same id as a counterpart whose `t()` collapses\n\t\t\t\t// that exit to no keyframes at all. `finishExit` clears the\n\t\t\t\t// same flag, so this reads as \"did the leg outlive the call\".\n\t\t\t\tif (exitStartedRef.current.has(rowId)) runsRef.current.set(rowId, run);\n\t\t\t}\n\n\t\t\tif (enterIdsRef.current.size > 0) {\n\t\t\t\tconst pending = Array.from(enterIdsRef.current);\n\t\t\t\tenterIdsRef.current.clear();\n\t\t\t\tfor (const rowId of pending) {\n\t\t\t\t\tconst element = rowElsRef.current.get(rowId);\n\t\t\t\t\tif (!element) continue;\n\t\t\t\t\tconst spec = rowEnter(\n\t\t\t\t\t\telement,\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tduration: prefersReducedMotion() ? 0 : DURATIONS.fast,\n\t\t\t\t\t\t\tdistance: 8,\n\t\t\t\t\t\t\teasing: JS_EASINGS.out,\n\t\t\t\t\t\t},\n\t\t\t\t\t\t{ direction: \"in\" }\n\t\t\t\t\t);\n\t\t\t\t\t// `let`, not `const`: on the reduced-motion path the finish\n\t\t\t\t\t// callback fires synchronously from inside runTransition,\n\t\t\t\t\t// before the binding is assigned — `run?.` covers that, and\n\t\t\t\t\t// there is nothing to abort there anyway.\n\t\t\t\t\tlet run: TransitionRun | undefined;\n\t\t\t\t\tlet settled = false;\n\t\t\t\t\trun = runTransition(element, spec, 1, undefined, () => {\n\t\t\t\t\t\tsettled = true;\n\t\t\t\t\t\trunsRef.current.delete(rowId);\n\t\t\t\t\t\t// On ENTER finish, abort the run: that removes the\n\t\t\t\t\t\t// `fill: forwards` so the row falls back to its resting\n\t\t\t\t\t\t// style — which *is* the visible end state by\n\t\t\t\t\t\t// construction. The exit path above deliberately does\n\t\t\t\t\t\t// NOT abort: its node stays in the DOM until React\n\t\t\t\t\t\t// processes the removal one render later, and dropping\n\t\t\t\t\t\t// fill-forwards would flash the row back for a frame.\n\t\t\t\t\t\trun?.abort();\n\t\t\t\t\t});\n\t\t\t\t\t// Same live-leg guard as the exit above: a duration-0\n\t\t\t\t\t// entrance has already finished and deleted itself, so\n\t\t\t\t\t// registering it again would strand a dead handle.\n\t\t\t\t\tif (!settled) runsRef.current.set(rowId, run);\n\t\t\t\t}\n\t\t\t}\n\t\t}, [rendered]);\n\n\t\t// Unmount: abort every in-flight leg so nothing fires into a dead tree.\n\t\tuseIsomorphicLayoutEffect(() => {\n\t\t\tconst runs = runsRef.current;\n\t\t\treturn () => {\n\t\t\t\tfor (const run of runs.values()) run.abort();\n\t\t\t\truns.clear();\n\t\t\t};\n\t\t}, []);\n\n\t\t// The single place new files enter the list, from either the native\n\t\t// picker or a drop — both funnel through here so maxSize/maxFiles/accept\n\t\t// are enforced identically regardless of path.\n\t\tfunction addFiles(incoming: File[]) {\n\t\t\tif (effectiveDisabled || incoming.length === 0) return;\n\n\t\t\tconst selected = multiple ? incoming : incoming.slice(0, 1);\n\t\t\t// A single-file picker replaces its previous selection rather than\n\t\t\t// stacking, matching how a native non-multiple input behaves.\n\t\t\tconst next = multiple ? [...files] : [];\n\t\t\t// Every rejection ends up here — a file kept out of the list entirely\n\t\t\t// by maxFiles, and a file added with status \"error\" by accept/maxSize\n\t\t\t// alike — because both need a message a screen reader actually reaches,\n\t\t\t// not just row text nothing moved focus to.\n\t\t\tconst problems: string[] = [];\n\t\t\tlet addedCount = 0;\n\n\t\t\t// Files past the first one in a drop onto a non-multiple zone are just\n\t\t\t// as much a rejection as anything below — the OS picker can't even\n\t\t\t// offer a multi-select when `multiple` is unset, so a drop is the only\n\t\t\t// path an extra file can arrive by at all. Discarding it without a word\n\t\t\t// would be an enhancement-path-only silent failure: the one path a\n\t\t\t// keyboard user cannot take (drag-and-drop) would behave differently\n\t\t\t// from the one they can (the picker, which physically can't produce\n\t\t\t// this case).\n\t\t\tfor (const file of incoming.slice(selected.length)) {\n\t\t\t\tproblems.push(`${file.name} was not added: only one file is accepted.`);\n\t\t\t}\n\n\t\t\tfor (const file of selected) {\n\t\t\t\tif (maxFiles !== undefined && next.length >= maxFiles) {\n\t\t\t\t\tproblems.push(\n\t\t\t\t\t\t`${file.name} was not added: the limit of ${maxFiles} file${maxFiles === 1 ? \"\" : \"s\"} has been reached.`\n\t\t\t\t\t);\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tif (!matchesAccept(file, accept)) {\n\t\t\t\t\tconst error = `${file.name} is not an accepted file type.`;\n\t\t\t\t\tnext.push({ id: createRowId(), file, progress: null, status: \"error\", error });\n\t\t\t\t\tproblems.push(error);\n\t\t\t\t\taddedCount += 1;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tif (maxSize !== undefined && file.size > maxSize) {\n\t\t\t\t\tconst error = `${file.name} exceeds the ${formatBytes(maxSize)} limit.`;\n\t\t\t\t\tnext.push({ id: createRowId(), file, progress: null, status: \"error\", error });\n\t\t\t\t\tproblems.push(error);\n\t\t\t\t\taddedCount += 1;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tnext.push({ id: createRowId(), file, progress: null, status: \"pending\" });\n\t\t\t\taddedCount += 1;\n\t\t\t}\n\n\t\t\t// Mirrors the liveMessage precedence right below: a rejection\n\t\t\t// anywhere in this batch wins the cue over an acceptance, and a\n\t\t\t// batch that added nothing plays nothing at all. Never `success` —\n\t\t\t// this component only tracks a selection/drop, it does not perform\n\t\t\t// the upload itself.\n\t\t\tconst cue: SoundCue | undefined =\n\t\t\t\tproblems.length > 0 ? \"error\" : addedCount > 0 ? \"select\" : undefined;\n\t\t\tcommitFiles(next, cue);\n\n\t\t\tif (problems.length > 0) {\n\t\t\t\tannounce(problems.join(\" \"));\n\t\t\t} else if (addedCount > 0) {\n\t\t\t\tannounce(`${addedCount} file${addedCount === 1 ? \"\" : \"s\"} added.`);\n\t\t\t}\n\t\t}\n\n\t\tfunction handleInputChange(event: ChangeEvent<HTMLInputElement>) {\n\t\t\tif (effectiveDisabled) return;\n\t\t\taddFiles(Array.from(event.currentTarget.files ?? []));\n\t\t\tonChange?.(event);\n\t\t}\n\n\t\t/**\n\t\t * The reset that keeps the SAME file re-selectable happens HERE, on the\n\t\t * click that opens the picker, not after the change it produces.\n\t\t *\n\t\t * Clearing on change is the usual spelling of this trick, and it is what\n\t\t * the Svelte source does — but this control documents `name` and\n\t\t * `required` on a real `<input type=\"file\">`, and an input cleared the\n\t\t * instant it changes carries no `FileList` into a native form submission:\n\t\t * the field posts nothing and `required` fails validation against a list\n\t\t * the reader can plainly see. Clearing at picker-open time leaves the\n\t\t * browser comparing the new pick against an empty value, so re-picking\n\t\t * the same file still counts as a change, and the pick then survives\n\t\t * until the next picker opens.\n\t\t *\n\t\t * Divergence from the Svelte source (which clears on change), with one\n\t\t * residual gap of its own: opening the picker and CANCELLING leaves the\n\t\t * input empty, so the native submission loses a pick the row list still\n\t\t * shows. Restoring it would need a `DataTransfer` round-trip. Drag-drop\n\t\t * has never fed the native input at all, on either side.\n\t\t *\n\t\t * The other end of that policy lives in `removeFile`, which clears the\n\t\t * input again the moment a removal takes one of its files out of the\n\t\t * list — the two together are what keep `required` from validating\n\t\t * against a file the reader can see is gone.\n\t\t */\n\t\tfunction handleInputClick(event: MouseEvent<HTMLInputElement>) {\n\t\t\tevent.currentTarget.value = \"\";\n\t\t\tonClick?.(event);\n\t\t}\n\n\t\tfunction handleDragEnter(event: DragEvent<HTMLDivElement>) {\n\t\t\tevent.preventDefault();\n\t\t\tif (effectiveDisabled) return;\n\t\t\tsetDragDepth((depth) => depth + 1);\n\t\t}\n\n\t\tfunction handleDragOver(event: DragEvent<HTMLDivElement>) {\n\t\t\t// Required on every dragover, disabled or not: without it the browser\n\t\t\t// never fires `drop` at all, and a disabled zone still needs `drop` to\n\t\t\t// fire so it can swallow the file instead of letting the browser\n\t\t\t// navigate to it.\n\t\t\tevent.preventDefault();\n\t\t}\n\n\t\tfunction handleDragLeave(event: DragEvent<HTMLDivElement>) {\n\t\t\tevent.preventDefault();\n\t\t\tif (effectiveDisabled) return;\n\t\t\tsetDragDepth((depth) => Math.max(0, depth - 1));\n\t\t}\n\n\t\tfunction handleDrop(event: DragEvent<HTMLDivElement>) {\n\t\t\tevent.preventDefault();\n\t\t\tsetDragDepth(0);\n\t\t\tif (effectiveDisabled) return;\n\t\t\tconst dropped = Array.from(event.dataTransfer?.files ?? []);\n\t\t\taddFiles(dropped);\n\t\t}\n\n\t\tfunction removeFile(fileId: string) {\n\t\t\tif (effectiveDisabled) return;\n\t\t\tconst index = files.findIndex((entry) => entry.id === fileId);\n\t\t\tif (index === -1) return;\n\n\t\t\tconst next = files.filter((entry) => entry.id !== fileId);\n\t\t\tcommitFiles(next);\n\n\t\t\t// The input keeps its picked FileList (see `handleInputClick`), and\n\t\t\t// that list is what a native submit posts and what `required`\n\t\t\t// validates against — so a removal that takes a picked file out of\n\t\t\t// the list has to take it off the input too. Without this, removing\n\t\t\t// the file just picked leaves `required` satisfied by a file the\n\t\t\t// reader can plainly see is gone, and a submit posts it.\n\t\t\tconst input = inputRef.current;\n\t\t\tif (input && input.files && input.files.length > 0) {\n\t\t\t\tconst remaining = new Set(next.map((entry) => entry.file));\n\t\t\t\tconst stale = Array.from(input.files).some((file) => !remaining.has(file));\n\t\t\t\tif (stale) input.value = \"\";\n\t\t\t}\n\n\t\t\t// The removed row's own button can't keep focus. Move it to the row\n\t\t\t// that slid into its place, the row before it if this was the last\n\t\t\t// one, or back to the picker once the list is empty, rather than\n\t\t\t// letting it fall back to <body>.\n\t\t\t//\n\t\t\t// The target is resolved from `next` by id, not by counting buttons\n\t\t\t// in the DOM, because the removed row has NOT left the DOM yet: its\n\t\t\t// exit transition is about to play, inert for the whole of it. A\n\t\t\t// DOM-order lookup would hand focus to the very button that is on\n\t\t\t// its way out — which browsers refuse to focus inside an inert\n\t\t\t// subtree, dropping focus on <body> exactly where this code exists\n\t\t\t// to stop it landing. Reading `next` gives the same answer whether\n\t\t\t// the exit runs its course or reduced motion collapses it to\n\t\t\t// nothing — and the surviving button is already in the DOM, so the\n\t\t\t// lookup can run right here rather than after a tick.\n\t\t\tconst target = next[Math.min(index, next.length - 1)];\n\t\t\tconst button = target\n\t\t\t\t? Array.from(listRef.current?.querySelectorAll<HTMLLIElement>(\"[data-file-row]\") ?? [])\n\t\t\t\t\t\t.find((row) => row.dataset.fileRow === target.id)\n\t\t\t\t\t\t?.querySelector<HTMLButtonElement>(\"[data-file-remove]\")\n\t\t\t\t: undefined;\n\t\t\tif (button) {\n\t\t\t\tbutton.focus();\n\t\t\t} else {\n\t\t\t\tinputRef.current?.focus();\n\t\t\t}\n\t\t}\n\n\t\tconst classes = cn(\"ft-file-upload flex flex-col gap-3\", className);\n\n\t\tconst dropzoneClasses = cn(\n\t\t\t\"ft-file-upload-dropzone flex flex-col items-center gap-1 rounded-[10px] border-[1.5px] border-dashed p-[18px] text-center transition-colors\",\n\t\t\teffectiveInvalid ? \"border-destructive/60\" : \"ft-file-upload-accent-border\",\n\t\t\tdragging && \"ft-file-upload-dragging\",\n\t\t\teffectiveDisabled && \"cursor-not-allowed opacity-50\"\n\t\t);\n\n\t\treturn (\n\t\t\t<div className={classes}>\n\t\t\t\t<div\n\t\t\t\t\tclassName={dropzoneClasses}\n\t\t\t\t\trole=\"presentation\"\n\t\t\t\t\tdata-dragging={dragging ? \"true\" : undefined}\n\t\t\t\t\tonDragEnter={handleDragEnter}\n\t\t\t\t\tonDragOver={handleDragOver}\n\t\t\t\t\tonDragLeave={handleDragLeave}\n\t\t\t\t\tonDrop={handleDrop}\n\t\t\t\t>\n\t\t\t\t\t<input\n\t\t\t\t\t\t{...rest}\n\t\t\t\t\t\tref={composedInputRef}\n\t\t\t\t\t\ttype=\"file\"\n\t\t\t\t\t\tid={effectiveId}\n\t\t\t\t\t\tname={name}\n\t\t\t\t\t\taccept={accept}\n\t\t\t\t\t\tmultiple={multiple}\n\t\t\t\t\t\tdisabled={effectiveDisabled}\n\t\t\t\t\t\trequired={effectiveRequired}\n\t\t\t\t\t\taria-invalid={effectiveInvalid ? \"true\" : ariaInvalid}\n\t\t\t\t\t\taria-describedby={describedBy}\n\t\t\t\t\t\taria-label={label ?? ariaLabel}\n\t\t\t\t\t\tclassName=\"ft-file-upload-input sr-only\"\n\t\t\t\t\t\tonClick={handleInputClick}\n\t\t\t\t\t\tonChange={handleInputChange}\n\t\t\t\t\t/>\n\t\t\t\t\t{/*\n\t\t\t\t\t\tA real, focusable `<input type=\"file\">` behind everything — this\n\t\t\t\t\t\t`<label>` is a click-target enhancement layered on top of it, not a\n\t\t\t\t\t\treplacement path. Keyboard users reach the input directly by Tab and\n\t\t\t\t\t\topen the picker with Enter/Space, same as any native file input; this\n\t\t\t\t\t\tlabel only makes the whole prompt (not just the input's own tiny\n\t\t\t\t\t\tnative button) clickable, and gives the input its default accessible\n\t\t\t\t\t\tname when the `label` prop and any surrounding FormField are both\n\t\t\t\t\t\tabsent. The drop zone itself (the div above) stays a plain, non-\n\t\t\t\t\t\tinteractive element — making it a button as well would nest two\n\t\t\t\t\t\tinteractive controls around the same input.\n\t\t\t\t\t*/}\n\t\t\t\t\t<label\n\t\t\t\t\t\thtmlFor={effectiveId}\n\t\t\t\t\t\tclassName=\"ft-file-upload-prompt flex flex-col items-center gap-1\"\n\t\t\t\t\t>\n\t\t\t\t\t\t<span aria-hidden=\"true\" className=\"ft-file-upload-icon\">\n\t\t\t\t\t\t\t⇪\n\t\t\t\t\t\t</span>\n\t\t\t\t\t\t<span className=\"text-[12px] font-medium\">\n\t\t\t\t\t\t\tDrag and drop or <span className=\"ft-file-upload-browse\">browse</span>\n\t\t\t\t\t\t</span>\n\t\t\t\t\t</label>\n\t\t\t\t\t{hint && (\n\t\t\t\t\t\t<p id={hintId} className=\"text-muted-foreground text-[11px]\">\n\t\t\t\t\t\t\t{hint}\n\t\t\t\t\t\t</p>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\n\t\t\t\t{rendered.length > 0 && (\n\t\t\t\t\t<ul ref={listRef} className=\"ft-file-upload-list flex flex-col gap-2\">\n\t\t\t\t\t\t{rendered.map(({ entry }) => (\n\t\t\t\t\t\t\t<li\n\t\t\t\t\t\t\t\tkey={entry.id}\n\t\t\t\t\t\t\t\tref={rowRefFor(entry.id)}\n\t\t\t\t\t\t\t\tdata-file-row={entry.id}\n\t\t\t\t\t\t\t\tclassName=\"border-border flex items-center gap-[10px] rounded-[8px] border px-[10px] py-[8px] text-[12px]\"\n\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t<span aria-hidden=\"true\">📄</span>\n\t\t\t\t\t\t\t\t<div className=\"flex min-w-0 flex-1 flex-col gap-1\">\n\t\t\t\t\t\t\t\t\t<span className=\"truncate\">{entry.file.name}</span>\n\t\t\t\t\t\t\t\t\t{entry.status === \"uploading\" ? (\n\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\trole=\"progressbar\"\n\t\t\t\t\t\t\t\t\t\t\taria-valuemin={0}\n\t\t\t\t\t\t\t\t\t\t\taria-valuemax={100}\n\t\t\t\t\t\t\t\t\t\t\taria-valuenow={entry.progress ?? undefined}\n\t\t\t\t\t\t\t\t\t\t\taria-label={`Uploading ${entry.file.name}`}\n\t\t\t\t\t\t\t\t\t\t\tclassName=\"ft-file-upload-progress-track\"\n\t\t\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t\t\t<div\n\t\t\t\t\t\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t\t\t\t\t\t\"ft-file-upload-progress-fill\",\n\t\t\t\t\t\t\t\t\t\t\t\t\tentry.progress === null && \"ft-file-upload-progress-indeterminate\"\n\t\t\t\t\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t\t\t\t\tstyle={\n\t\t\t\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t[\"--ft-fileupload-progress\" as string]:\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\tentry.progress !== null ? entry.progress / 100 : undefined,\n\t\t\t\t\t\t\t\t\t\t\t\t\t} as CSSProperties\n\t\t\t\t\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t\t\t\t\t></div>\n\t\t\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t\t) : entry.status === \"error\" && entry.error ? (\n\t\t\t\t\t\t\t\t\t\t<p className=\"text-destructive\">{entry.error}</p>\n\t\t\t\t\t\t\t\t\t) : entry.status === \"done\" ? (\n\t\t\t\t\t\t\t\t\t\t<span className=\"ft-file-upload-done flex items-center gap-1\">\n\t\t\t\t\t\t\t\t\t\t\t<span aria-hidden=\"true\">✓</span> Done\n\t\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t\t) : null}\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t\t{entry.status === \"uploading\" && entry.progress !== null && (\n\t\t\t\t\t\t\t\t\t<span aria-hidden=\"true\" className=\"text-muted-foreground\">\n\t\t\t\t\t\t\t\t\t\t{entry.progress}%\n\t\t\t\t\t\t\t\t\t</span>\n\t\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t\t\t<button\n\t\t\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\t\t\tdata-file-remove\n\t\t\t\t\t\t\t\t\tdisabled={effectiveDisabled}\n\t\t\t\t\t\t\t\t\taria-label={`Remove ${entry.file.name}`}\n\t\t\t\t\t\t\t\t\tclassName=\"ft-file-upload-remove text-muted-foreground shrink-0\"\n\t\t\t\t\t\t\t\t\tonClick={() => removeFile(entry.id)}\n\t\t\t\t\t\t\t\t>\n\t\t\t\t\t\t\t\t\t✕\n\t\t\t\t\t\t\t\t</button>\n\t\t\t\t\t\t\t</li>\n\t\t\t\t\t\t))}\n\t\t\t\t\t</ul>\n\t\t\t\t)}\n\n\t\t\t\t<div aria-live=\"polite\" className=\"sr-only\">\n\t\t\t\t\t{liveMessage}\n\t\t\t\t</div>\n\t\t\t</div>\n\t\t);\n\t}\n);\n\nFileUpload.displayName = \"FileUpload\";\n","type":"registry:ui","target":"@ui/fancy/components/file-upload/FileUpload.tsx"},{"path":"react/src/components/file-upload/file-upload.css","content":"/*\n * The brand accent has no semantic token, so it is declared locally with a\n * light-dark() fallback — the same shape Input uses for its own focus\n * ring — on the component root rather than the dropzone alone, since the\n * file rows' progress fills read it too and they are the dropzone's\n * sibling, not its descendant. Every other rule below reads this one\n * custom property, so retinting --ft-accent higher up the tree moves the\n * dashed border, the dragging state, the focus halo and the progress\n * fills together.\n */\n.ft-file-upload {\n\t--ft-field-accent: var(\n\t\t--ft-accent,\n\t\tlight-dark(oklch(0.5432 0.2528 300.22), oklch(0.604 0.2606 301.75))\n\t);\n}\n\n.ft-file-upload-dropzone {\n\tbackground-color: color-mix(in oklab, var(--ft-field-accent) 4%, transparent);\n}\n\n.ft-file-upload-accent-border {\n\tborder-color: color-mix(in oklab, var(--ft-field-accent) 40%, transparent);\n}\n\n.ft-file-upload-dragging {\n\tborder-color: var(--ft-field-accent);\n\tbackground-color: color-mix(in oklab, var(--ft-field-accent) 10%, transparent);\n}\n\n/* Keyboard focus lands on the sr-only input, not the dropzone — this is\n   what actually surfaces that focus to a sighted user. :has() runs off\n   the input's own :focus-visible, so it never lights up on a mouse click,\n   only on the same conditions the input's own ring would use if it were\n   visible. */\n.ft-file-upload-dropzone:has(.ft-file-upload-input:focus-visible) {\n\tborder-color: var(--ft-field-accent);\n\tborder-style: solid;\n\tbox-shadow: 0 0 0 3px color-mix(in oklab, var(--ft-field-accent) 25%, transparent);\n}\n\n.ft-file-upload-browse {\n\tcolor: var(--ft-field-accent);\n}\n\n.ft-file-upload-icon {\n\tfont-size: 18px;\n\tcolor: var(--ft-field-accent);\n}\n\n.ft-file-upload-progress-track {\n\tposition: relative;\n\theight: 3px;\n\tborder-radius: 2px;\n\toverflow: hidden;\n\tbackground-color: var(--color-muted, rgba(255, 255, 255, 0.08));\n}\n\n/*\n * scaleX off a custom property, never `width` — the same technique\n * ScrollProgress ships for the same reason: a width change forces layout on\n * every progress report a consumer pushes in, while a transform is\n * compositor-only. The property carries a 0–1 ratio (70% is `0.7`), so the\n * component never has to parse a percentage back out of a style string.\n * `transform-origin: left` makes the bar grow from the start edge.\n */\n.ft-file-upload-progress-fill {\n\tposition: absolute;\n\tinset: 0;\n\tborder-radius: 2px;\n\tbackground-color: var(--ft-field-accent, currentColor);\n\ttransform: scaleX(var(--ft-fileupload-progress, 0));\n\ttransform-origin: left;\n}\n\n/*\n * `width: 40%` is layout, not animation: it sizes the travelling block once\n * and never changes it, which is why it stays a `width` rather than becoming\n * another transform. `transform: none` un-does the determinate rule above —\n * without it an indeterminate bar under reduced motion (where the keyframes\n * never run) would sit at `scaleX(0)` and vanish, where today it shows a\n * static block. The keyframes below out-rank this declaration whenever the\n * animation is actually running.\n */\n.ft-file-upload-progress-indeterminate {\n\twidth: 40%;\n\ttransform: none;\n}\n\n@media (prefers-reduced-motion: no-preference) {\n\t/* A bar that eases toward each reported value instead of jumping to it is\n\t   the whole user-visible point of the swap above. tokens.ts:\n\t   DURATIONS.fast / EASINGS.out. */\n\t.ft-file-upload-progress-fill {\n\t\ttransition: transform var(--ft-duration-fast, 150ms)\n\t\t\tvar(--ft-ease-out, cubic-bezier(0.16, 1, 0.3, 1));\n\t}\n\n\t.ft-file-upload-progress-indeterminate {\n\t\t/* tokens.* n/a: loop period, component-owned */\n\t\tanimation: ft-file-upload-indeterminate 1.4s\n\t\t\tvar(--ft-ease-inout, cubic-bezier(0.4, 0, 0.2, 1)) infinite;\n\t}\n}\n\n/* translateX, not margin-left: a margin animation re-runs layout on every\n   frame of a loop that never stops. The arithmetic: the block is 40% of the\n   track wide, so -100% parks it fully off the left edge and 250% (2.5 × 40%)\n   carries it fully off the right. */\n@keyframes ft-file-upload-indeterminate {\n\t0% {\n\t\ttransform: translateX(-100%);\n\t}\n\t100% {\n\t\ttransform: translateX(250%);\n\t}\n}\n\n.ft-file-upload-done {\n\tcolor: var(--ft-status-done, light-dark(oklch(0.5 0.14 145), oklch(0.72 0.15 145)));\n}\n\n.ft-file-upload-remove {\n\tfont-size: 11px;\n\tline-height: 1;\n\tpadding: 4px;\n\tborder-radius: 999px;\n}\n\n.ft-file-upload-remove:hover:not(:disabled) {\n\tbackground-color: color-mix(in oklab, currentColor 10%, transparent);\n}\n\n.ft-file-upload-remove:disabled {\n\topacity: 0.5;\n\tcursor: not-allowed;\n}\n","type":"registry:ui","target":"@ui/fancy/components/file-upload/file-upload.css"},{"path":"react/src/components/file-upload/index.ts","content":"\"use client\";\n\nexport { FileUpload } from \"./FileUpload\";\nexport type { FileUploadProps, UploadFile } from \"./FileUpload\";\n","type":"registry:ui","target":"@ui/fancy/components/file-upload/index.ts"}],"meta":{"license":"MIT","package":"fancy-ui-react","version":"0.7.0","source":"https://fancy-ui.rama.app/docs/components/file-upload?framework=react"}}