{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-input",
  "title": "File Input",
  "description": "Single file or image input with preview, drag-and-drop and size validation.",
  "dependencies": [
    "lucide-react@^1.48.0"
  ],
  "registryDependencies": [
    "https://rdf.dev.360online.com/r/button.json"
  ],
  "files": [
    {
      "path": "registry/rapid/ui/file-input/file-input.tsx",
      "content": "'use client'\n\n// NOTE: UI strings are hardcoded in English. This is a template component — wire it to your app's i18n when adopting it.\nimport { Trash2Icon, UploadIcon } from 'lucide-react'\nimport * as React from 'react'\nimport { useEffect, useState } from 'react'\nimport { Button } from '@/components/ui/button'\nimport { cn } from '@/lib/utils'\n\ninterface FileInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value'> {\n\tinitialImage?: string\n\tvalue?: File | null\n\tonImageChange?: (file: File | null) => void\n\treadOnly?: boolean\n\tdisabled?: boolean\n\t/**\n\t * Set on the button that opens the file picker, so a `<label htmlFor>` or `FieldLabel` can point at it. The\n\t * `aria-label`, `aria-labelledby`, `aria-describedby` and `aria-invalid` props go to the same button.\n\t */\n\tid?: string\n\t'aria-invalid'?: boolean | 'true' | 'false'\n\t/** Maximum file size in bytes */\n\tmaxSize?: number\n}\n\nexport function FileInput({\n\tinitialImage,\n\tvalue,\n\tonImageChange,\n\treadOnly = false,\n\tdisabled = false,\n\taccept = 'image/*',\n\tid,\n\t'aria-label': ariaLabel,\n\t'aria-labelledby': ariaLabelledBy,\n\t'aria-describedby': ariaDescribedBy,\n\t'aria-invalid': ariaInvalid,\n\tmaxSize,\n}: FileInputProps = {}) {\n\tconst ref = React.useRef<HTMLInputElement>(null)\n\tconst errorId = React.useId()\n\tconst [preview, setPreview] = useState<string | null>(initialImage || null)\n\tconst [file, setFile] = useState<File | null>(value || null)\n\tconst [isDragging, setIsDragging] = useState(false)\n\tconst [sizeError, setSizeError] = useState<string | null>(null)\n\n\tuseEffect(() => {\n\t\tif (initialImage) {\n\t\t\tsetPreview(initialImage)\n\t\t}\n\t}, [initialImage])\n\n\tconst handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {\n\t\tconst selectedFile = event.target.files?.[0]\n\t\tprocessFile(selectedFile)\n\t}\n\n\tconst processFile = (selectedFile: File | undefined) => {\n\t\tif (selectedFile) {\n\t\t\tif (selectedFile.size === 0) {\n\t\t\t\tsetSizeError('File is empty. Please choose a file with content.')\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// Check file size if maxSize is specified\n\t\t\tif (maxSize && selectedFile.size > maxSize) {\n\t\t\t\tconst maxSizeKB = Math.round(maxSize / 1024)\n\t\t\t\tsetSizeError(`File is too large. Maximum size: ${maxSizeKB} KB`)\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tsetSizeError(null)\n\t\t\tsetFile(selectedFile)\n\t\t\tconst reader = new FileReader()\n\t\t\treader.onloadend = () => {\n\t\t\t\tsetPreview(reader.result as string)\n\t\t\t}\n\n\t\t\treader.readAsDataURL(selectedFile)\n\n\t\t\tonImageChange?.(selectedFile)\n\t\t}\n\t}\n\n\tconst handleRemove = () => {\n\t\tsetFile(null)\n\t\tsetPreview(null)\n\t\tonImageChange?.(null)\n\t\tif (ref.current) {\n\t\t\tref.current.value = ''\n\t\t}\n\t}\n\n\tconst handleDragOver = (e: React.DragEvent) => {\n\t\te.preventDefault()\n\t\tif (!readOnly && !disabled) {\n\t\t\tsetIsDragging(true)\n\t\t}\n\t}\n\n\tconst handleDragLeave = (e: React.DragEvent) => {\n\t\te.preventDefault()\n\t\tsetIsDragging(false)\n\t}\n\n\tconst handleDrop = (e: React.DragEvent) => {\n\t\te.preventDefault()\n\t\tsetIsDragging(false)\n\t\tif (!readOnly && !disabled) {\n\t\t\tconst droppedFile = e.dataTransfer.files?.[0]\n\t\t\tprocessFile(droppedFile)\n\t\t}\n\t}\n\n\tconst isDisabledOrReadOnly = disabled || readOnly\n\n\t// The file input itself is hidden, so the label and description go to the button that opens the picker.\n\tconst controlProps = {\n\t\tid,\n\t\t'aria-label': ariaLabel,\n\t\t'aria-labelledby': ariaLabelledBy,\n\t\t'aria-describedby': [ariaDescribedBy, sizeError ? errorId : undefined].filter(Boolean).join(' ') || undefined,\n\t\t'aria-invalid': ariaInvalid,\n\t}\n\n\treturn (\n\t\t<div className=\"space-y-2\">\n\t\t\t{/* Hidden file input */}\n\t\t\t<input ref={ref} type=\"file\" onChange={handleFileChange} accept={accept} disabled={disabled} className=\"hidden\" />\n\t\t\t{sizeError && (\n\t\t\t\t<p id={errorId} className=\"text-sm text-destructive\">\n\t\t\t\t\t{sizeError}\n\t\t\t\t</p>\n\t\t\t)}\n\n\t\t\t{!file && !preview ? (\n\t\t\t\t<button\n\t\t\t\t\ttype=\"button\"\n\t\t\t\t\tonClick={() => ref.current?.click()}\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\tdisabled={isDisabledOrReadOnly}\n\t\t\t\t\t{...controlProps}\n\t\t\t\t\tdata-dragging={isDragging || undefined}\n\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t'flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed border-input bg-background px-6 py-4 text-sm text-muted-foreground transition-colors outline-none',\n\t\t\t\t\t\t'hover:border-primary hover:bg-primary/5 data-dragging:border-primary data-dragging:bg-primary/5',\n\t\t\t\t\t\t'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring',\n\t\t\t\t\t\t'aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:hover:border-destructive dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',\n\t\t\t\t\t\t'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50',\n\t\t\t\t\t)}\n\t\t\t\t>\n\t\t\t\t\t<UploadIcon className=\"size-4\" />\n\t\t\t\t\t<span>Choose file or drag here</span>\n\t\t\t\t</button>\n\t\t\t) : (\n\t\t\t\t<div className=\"flex items-center gap-3\">\n\t\t\t\t\t<span className=\"sr-only\">Preview</span>\n\t\t\t\t\t{preview && (\n\t\t\t\t\t\t<button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tonClick={() => ref.current?.click()}\n\t\t\t\t\t\t\tdisabled={isDisabledOrReadOnly}\n\t\t\t\t\t\t\t{...controlProps}\n\t\t\t\t\t\t\tclassName={cn(\n\t\t\t\t\t\t\t\t'group relative rounded-lg border border-border bg-muted/50 p-2 transition-colors outline-none',\n\t\t\t\t\t\t\t\t!isDisabledOrReadOnly && 'cursor-pointer hover:border-primary hover:bg-muted',\n\t\t\t\t\t\t\t\t'focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring',\n\t\t\t\t\t\t\t\t'aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40',\n\t\t\t\t\t\t\t\t'disabled:cursor-not-allowed',\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<img src={preview} alt=\"Preview\" className=\"h-16 w-auto object-contain\" />\n\t\t\t\t\t\t\t{!isDisabledOrReadOnly && (\n\t\t\t\t\t\t\t\t<div className=\"absolute inset-0 flex items-center justify-center rounded-lg bg-black/50 opacity-0 transition-opacity group-hover:opacity-100\">\n\t\t\t\t\t\t\t\t\t<UploadIcon className=\"size-4 text-white\" />\n\t\t\t\t\t\t\t\t</div>\n\t\t\t\t\t\t\t)}\n\t\t\t\t\t\t</button>\n\t\t\t\t\t)}\n\t\t\t\t\t{!isDisabledOrReadOnly && (\n\t\t\t\t\t\t<Button\n\t\t\t\t\t\t\ttype=\"button\"\n\t\t\t\t\t\t\tvariant=\"ghost\"\n\t\t\t\t\t\t\tsize=\"icon\"\n\t\t\t\t\t\t\tonClick={handleRemove}\n\t\t\t\t\t\t\tclassName=\"text-muted-foreground hover:bg-destructive/10 hover:text-destructive active:bg-destructive/20 dark:hover:bg-destructive/10\"\n\t\t\t\t\t\t>\n\t\t\t\t\t\t\t<Trash2Icon />\n\t\t\t\t\t\t\t<span className=\"sr-only\">Remove</span>\n\t\t\t\t\t\t</Button>\n\t\t\t\t\t)}\n\t\t\t\t</div>\n\t\t\t)}\n\t\t</div>\n\t)\n}\n",
      "type": "registry:ui",
      "target": "components/ui/file-input.tsx"
    }
  ],
  "docs": "UI strings in this component are hardcoded in English. It is a template component: wire it to your app's i18n (e.g. next-intl) after installing.",
  "type": "registry:ui"
}