{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"file-upload","type":"registry:component","title":"File Upload","description":"Dropzone that also opens the native file picker, with a list below it that pops each file in and collapses smoothly on remove.","author":"Ryan","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/file-upload.tsx","type":"registry:component","target":"@components/motion/file-upload.tsx","content":"\"use client\";\n// easeui.dev/components/motion/file-upload\n\nimport { File as FileIcon, Upload, X } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type DragEvent, useId, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface FileUploadProps {\n  /** Controlled list of picked files. */\n  value?: File[];\n  /** Starting files when uncontrolled. Default []. */\n  defaultValue?: File[];\n  onChange?: (files: File[]) => void;\n  /** MIME types or extensions accepted, passed to the native file input. */\n  accept?: string;\n  /** Allows picking or dropping more than one file at once. Default true. */\n  multiple?: boolean;\n  /** Largest a single file may be, in bytes. Larger files are rejected with an inline error. */\n  maxSize?: number;\n  disabled?: boolean;\n  className?: string;\n}\n\nfunction formatSize(bytes: number): string {\n  if (bytes < 1024) return `${bytes} B`;\n  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;\n  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\n/**\n * A dropzone that also opens the native file picker on click. Picked files\n * land in a list below it, popping in on arrival, collapsing on remove.\n * Nothing here uploads anything: it just hands back File objects.\n */\nexport function FileUpload({\n  value,\n  defaultValue = [],\n  onChange,\n  accept,\n  multiple = true,\n  maxSize,\n  disabled = false,\n  className,\n}: FileUploadProps) {\n  const reduce = useReducedMotion();\n  const inputId = useId();\n  const dragCount = useRef(0);\n  const errorTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const [uncontrolled, setUncontrolled] = useState(defaultValue);\n  const [dragging, setDragging] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n  const isControlled = value !== undefined;\n  const files = isControlled ? value : uncontrolled;\n\n  const setFiles = (next: File[]) => {\n    if (!isControlled) setUncontrolled(next);\n    onChange?.(next);\n  };\n\n  const showError = (message: string) => {\n    setError(message);\n    if (errorTimer.current) clearTimeout(errorTimer.current);\n    errorTimer.current = setTimeout(() => setError(null), 3000);\n  };\n\n  const addFiles = (incoming: File[]) => {\n    const accepted: File[] = [];\n    let oversized: File | undefined;\n    for (const file of incoming) {\n      if (maxSize && file.size > maxSize) {\n        oversized = file;\n        continue;\n      }\n      accepted.push(file);\n    }\n    if (oversized) showError(`${oversized.name} is larger than ${formatSize(maxSize as number)}.`);\n    if (accepted.length === 0) return;\n    setFiles(multiple ? [...files, ...accepted] : accepted.slice(0, 1));\n  };\n\n  const onDrop = (event: DragEvent<HTMLLabelElement>) => {\n    event.preventDefault();\n    dragCount.current = 0;\n    setDragging(false);\n    if (!disabled) addFiles(Array.from(event.dataTransfer.files));\n  };\n\n  const onDragEnter = (event: DragEvent<HTMLLabelElement>) => {\n    event.preventDefault();\n    dragCount.current += 1;\n    if (!disabled) setDragging(true);\n  };\n\n  const onDragLeave = (event: DragEvent<HTMLLabelElement>) => {\n    event.preventDefault();\n    dragCount.current = Math.max(0, dragCount.current - 1);\n    if (dragCount.current === 0) setDragging(false);\n  };\n\n  const remove = (index: number) => setFiles(files.filter((_, i) => i !== index));\n\n  return (\n    <div className={cn(\"flex flex-col gap-3\", className)}>\n      <label\n        htmlFor={inputId}\n        data-dragging={dragging}\n        onDragEnter={onDragEnter}\n        onDragOver={(event) => event.preventDefault()}\n        onDragLeave={onDragLeave}\n        onDrop={onDrop}\n        className={cn(\n          \"flex touch-manipulation flex-col items-center gap-2 rounded-2xl border-2 border-dashed border-border px-6 py-10 text-center transition-colors duration-150 ease-out\",\n          disabled\n            ? \"cursor-not-allowed opacity-50\"\n            : \"cursor-pointer hover:border-border-strong hover:bg-muted/40\",\n          \"data-[dragging=true]:border-accent data-[dragging=true]:bg-accent/5\",\n        )}\n      >\n        <Upload aria-hidden=\"true\" className=\"h-5 w-5 text-muted-foreground\" />\n        <p className=\"text-sm text-foreground\">\n          <span className=\"font-medium\">Click to upload</span> or drag and drop\n        </p>\n        {accept ? <p className=\"text-xs text-muted-foreground\">{accept.split(\",\").join(\", \")}</p> : null}\n        <input\n          id={inputId}\n          type=\"file\"\n          accept={accept}\n          multiple={multiple}\n          disabled={disabled}\n          onChange={(event) => {\n            if (event.target.files) addFiles(Array.from(event.target.files));\n            event.target.value = \"\";\n          }}\n          className=\"sr-only\"\n        />\n      </label>\n\n      {error ? <p className=\"text-xs text-destructive\">{error}</p> : null}\n\n      {files.length > 0 ? (\n        <ul className=\"flex flex-col gap-1.5\">\n          <AnimatePresence initial={false}>\n            {files.map((file, index) => (\n              <motion.li\n                key={`${file.name}-${file.size}-${file.lastModified}`}\n                layout={!reduce}\n                initial={reduce ? false : { opacity: 0, y: 6, height: 0 }}\n                animate={{ opacity: 1, y: 0, height: \"auto\" }}\n                exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0 }}\n                transition={{ duration: 0.18, ease: EASE_OUT }}\n                className=\"flex items-center gap-2.5 overflow-hidden rounded-xl bg-card px-3 py-2 shadow-[0_0_0_1px_var(--border)]\"\n              >\n                <FileIcon aria-hidden=\"true\" className=\"h-4 w-4 shrink-0 text-muted-foreground\" />\n                <span className=\"min-w-0 flex-1 truncate text-sm text-foreground\">{file.name}</span>\n                <span className=\"shrink-0 text-xs text-muted-foreground\">{formatSize(file.size)}</span>\n                <button\n                  type=\"button\"\n                  aria-label={`Remove ${file.name}`}\n                  onClick={() => remove(index)}\n                  className=\"relative -mr-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors duration-150 after:absolute after:-inset-2 hover:bg-muted hover:text-foreground\"\n                >\n                  <X aria-hidden=\"true\" className=\"h-3.5 w-3.5\" />\n                </button>\n              </motion.li>\n            ))}\n          </AnimatePresence>\n        </ul>\n      ) : null}\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Micro-interactions run 100 to 150ms, standard UI\n// 150 to 250ms, and panels up to 300ms.\n// Easing curves mirror the CSS custom properties in globals.css.\n\n/** ease-out-quint. Fast start that settles quickly. Entrances, exits, feedback. */\nexport const EASE_OUT = [0.23, 1, 0.32, 1] as const;\n/** ease-in-out-cubic. Elements already on screen moving to a new spot. */\nexport const EASE_IN_OUT = [0.645, 0.045, 0.355, 1] as const;\n/** Sheet and drawer glide. */\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.23, 1, 0.32, 1)\";\n\n// Springs are described by duration and bounce, which is easier to reason about\n// than stiffness and damping. Bounce stays at zero for product UI.\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  duration: 0.15,\n  bounce: 0,\n} as const;\n\n/** Content swaps, label and icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  duration: 0.2,\n  bounce: 0,\n} as const;\n\n/** Overlay panel entrances, modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  duration: 0.25,\n  bounce: 0,\n} as const;\n\n/** Shared-layout glides, pills and indicators moving between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  duration: 0.22,\n  bounce: 0,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt). */\nexport const SPRING_MOUSE = {\n  stiffness: 320,\n  damping: 26,\n  mass: 0.3,\n} as const;\n\n/** Dragged handles and fills (sliders). Critically damped `useSpring` config,\n * so the value follows the pointer closely and never rebounds off an end. */\nexport const SPRING_GLIDE = {\n  stiffness: 700,\n  damping: 50,\n  mass: 0.5,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}