{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "hopper",
  "type": "registry:ui",
  "title": "Hopper",
  "description": "A file-upload dropzone over a faint coordinate grid: dragging a file over it lifts the zone and brightens the grid, and dropped files seat in as stacked cards showing name, size, and type — each removable by pointer or keyboard. Backed by a real hidden file input with type, size, and count validation; the zone opens the native picker on click, Enter, or Space.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/inputs/hopper/hopper.tsx",
      "type": "registry:ui",
      "target": "components/crucible/hopper.tsx",
      "content": "\"use client\";\n\nimport { AnimatePresence, motion } from \"motion/react\";\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\n/** A file the dropzone is currently holding, with a stable id for keys/focus. */\nexport interface HopperFile {\n  /** Stable, deterministic id assigned when the file was accepted. */\n  id: string;\n  /** The underlying native File object. */\n  file: File;\n}\n\n/** Why a file was turned away, surfaced to `onReject`. */\nexport type HopperRejectReason = \"type\" | \"size\" | \"count\";\n\nexport interface HopperRejection {\n  file: File;\n  reason: HopperRejectReason;\n}\n\nexport interface HopperProps {\n  /**\n   * Native `accept` string, honoured for BOTH the picker and drag-drop\n   * (drops bypass the input's own filtering, so it is re-checked here).\n   * e.g. \"image/*,.pdf\". @default undefined — any file\n   */\n  accept?: string;\n  /** Allow more than one file. When false, a new file replaces the last. @default true */\n  multiple?: boolean;\n  /** Max bytes per file; larger files are rejected. @default undefined — no limit */\n  maxSize?: number;\n  /** Max number of files held at once; extras are rejected. @default undefined — no limit */\n  maxCount?: number;\n  /** Fires whenever the held set changes, with the native files in order. */\n  onChange?: (files: File[]) => void;\n  /** Fires with any files turned away (wrong type / too large / over count). */\n  onReject?: (rejections: HopperRejection[]) => void;\n  /** Headline inside the zone. @default \"Drop files or click to browse\" */\n  label?: React.ReactNode;\n  /** Sub-line under the headline; defaults to a generated accept/size summary. */\n  hint?: React.ReactNode;\n  /**\n   * Per-file card slot — replaces the default card body (the surrounding\n   * focusable `<li>` with Delete-to-remove is always kept for a11y).\n   */\n  renderCard?: (args: {\n    file: File;\n    id: string;\n    index: number;\n    remove: () => void;\n  }) => React.ReactNode;\n  /** Progress slot rendered inside each default card, e.g. an upload bar. */\n  renderProgress?: (args: { file: File; id: string }) => React.ReactNode;\n  /** The single cool accent for the active drag border/glow. @default sky */\n  accent?: string;\n  /** Disable the whole control. @default false */\n  disabled?: boolean;\n  /** Freeze motion (grid brighten, card spring). @default false */\n  paused?: boolean;\n  className?: string;\n}\n\nconst DEFAULT_ACCENT = \"#38bdf8\";\n\n/** Deterministic, monotonic id — no Math.random (SSR/hydration safe). */\nfunction useIdFactory() {\n  const counter = React.useRef(0);\n  return React.useCallback(() => `hopper-file-${(counter.current += 1)}`, []);\n}\n\nfunction formatBytes(bytes: number): string {\n  if (!Number.isFinite(bytes) || bytes <= 0) return \"0 B\";\n  const units = [\"B\", \"KB\", \"MB\", \"GB\", \"TB\"];\n  const k = 1024;\n  const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1);\n  const v = bytes / Math.pow(k, i);\n  return `${v >= 10 || i === 0 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;\n}\n\nfunction extensionOf(name: string): string {\n  const dot = name.lastIndexOf(\".\");\n  return dot > 0 ? name.slice(dot + 1).toUpperCase() : \"FILE\";\n}\n\n/** Does a file satisfy an `accept` token list? Empty accept = anything. */\nfunction acceptMatches(file: File, accept?: string): boolean {\n  if (!accept || !accept.trim()) return true;\n  const name = file.name.toLowerCase();\n  const type = file.type.toLowerCase();\n  return accept\n    .split(\",\")\n    .map((t) => t.trim().toLowerCase())\n    .filter(Boolean)\n    .some((token) => {\n      if (token.startsWith(\".\")) return name.endsWith(token);\n      if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n      return type === token;\n    });\n}\n\n/**\n * Hopper — a file-upload dropzone over a faint coordinate grid. Dragging a file\n * over the zone lifts it and brightens the grid; on drop it settles and the\n * files seat in as stacked cards (name / size / type) that each carry a soft\n * downward inner-shadow. Cards are removable by pointer or keyboard (focus a\n * card, press Delete). Clicking the zone opens the native picker via a real\n * hidden `<input type=\"file\">`; the zone is a button, so Enter/Space open it\n * too. Types, per-file size, and total count are validated for drops and picks\n * alike, with turned-away files reported to `onReject`. Reduced motion drops the\n * lift and card spring and holds the grid static — cards appear instantly.\n */\nexport function Hopper({\n  accept,\n  multiple = true,\n  maxSize,\n  maxCount,\n  onChange,\n  onReject,\n  label = \"Drop files or click to browse\",\n  hint,\n  renderCard,\n  renderProgress,\n  accent = DEFAULT_ACCENT,\n  disabled = false,\n  paused = false,\n  className,\n}: HopperProps) {\n  const reducedMotion = useReducedMotion();\n  const still = reducedMotion || paused;\n\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const buttonRef = React.useRef<HTMLButtonElement>(null);\n  const cardRefs = React.useRef(new Map<string, HTMLLIElement>());\n  const pendingFocus = React.useRef<string | \"zone\" | null>(null);\n  const dragDepth = React.useRef(0);\n  const nextId = useIdFactory();\n\n  const [files, setFiles] = React.useState<HopperFile[]>([]);\n  const [dragActive, setDragActive] = React.useState(false);\n\n  const emit = React.useCallback(\n    (next: HopperFile[]) => {\n      setFiles(next);\n      onChange?.(next.map((f) => f.file));\n    },\n    [onChange]\n  );\n\n  const ingest = React.useCallback(\n    (list: FileList | File[]) => {\n      if (disabled) return;\n      const incoming = Array.from(list);\n      if (incoming.length === 0) return;\n\n      const rejected: HopperRejection[] = [];\n      const accepted: File[] = [];\n      for (const file of incoming) {\n        if (!acceptMatches(file, accept)) {\n          rejected.push({ file, reason: \"type\" });\n        } else if (maxSize != null && file.size > maxSize) {\n          rejected.push({ file, reason: \"size\" });\n        } else {\n          accepted.push(file);\n        }\n      }\n\n      if (!multiple) {\n        if (accepted.length > 0) {\n          // Keep the last valid file; report the rest as count overflow.\n          accepted.slice(0, -1).forEach((file) => rejected.push({ file, reason: \"count\" }));\n          emit([{ id: nextId(), file: accepted[accepted.length - 1] }]);\n        }\n      } else {\n        const room = maxCount != null ? Math.max(0, maxCount - files.length) : Infinity;\n        const toAdd = accepted.slice(0, room);\n        accepted.slice(room).forEach((file) => rejected.push({ file, reason: \"count\" }));\n        if (toAdd.length > 0) {\n          emit([...files, ...toAdd.map((file) => ({ id: nextId(), file }))]);\n        }\n      }\n\n      if (rejected.length > 0) onReject?.(rejected);\n    },\n    [accept, disabled, emit, files, maxCount, maxSize, multiple, nextId, onReject]\n  );\n\n  const remove = React.useCallback(\n    (id: string) => {\n      const idx = files.findIndex((f) => f.id === id);\n      if (idx === -1) return;\n      // Decide where focus should land after the card leaves.\n      const nextTarget = files[idx + 1]?.id ?? files[idx - 1]?.id ?? \"zone\";\n      pendingFocus.current = nextTarget;\n      cardRefs.current.delete(id);\n      emit(files.filter((f) => f.id !== id));\n    },\n    [emit, files]\n  );\n\n  // After a removal, move focus to the adjacent card (or back to the zone).\n  React.useEffect(() => {\n    const target = pendingFocus.current;\n    if (!target) return;\n    pendingFocus.current = null;\n    if (target === \"zone\") buttonRef.current?.focus();\n    else cardRefs.current.get(target)?.focus();\n  }, [files]);\n\n  const openPicker = React.useCallback(() => {\n    if (disabled) return;\n    inputRef.current?.click();\n  }, [disabled]);\n\n  const onDragOver = (e: React.DragEvent) => {\n    if (disabled) return;\n    e.preventDefault();\n    if (e.dataTransfer) e.dataTransfer.dropEffect = \"copy\";\n  };\n  const onDragEnter = (e: React.DragEvent) => {\n    if (disabled) return;\n    e.preventDefault();\n    dragDepth.current += 1;\n    if (dragDepth.current === 1) setDragActive(true);\n  };\n  const onDragLeave = (e: React.DragEvent) => {\n    if (disabled) return;\n    e.preventDefault();\n    dragDepth.current = Math.max(0, dragDepth.current - 1);\n    if (dragDepth.current === 0) setDragActive(false);\n  };\n  const onDrop = (e: React.DragEvent) => {\n    if (disabled) return;\n    e.preventDefault();\n    dragDepth.current = 0;\n    setDragActive(false);\n    if (e.dataTransfer?.files?.length) ingest(e.dataTransfer.files);\n  };\n\n  const defaultHint =\n    hint ??\n    [\n      accept ? accept.replace(/,/g, \", \") : \"Any file type\",\n      maxSize != null ? `up to ${formatBytes(maxSize)} each` : null,\n      maxCount != null ? `max ${maxCount}` : multiple ? null : \"single file\",\n    ]\n      .filter(Boolean)\n      .join(\" · \");\n\n  const cardTransition = still\n    ? { duration: 0 }\n    : ({ type: \"spring\", stiffness: 520, damping: 34 } as const);\n\n  return (\n    <div\n      data-crucible=\"hopper\"\n      className={cn(\"flex w-full max-w-md flex-col gap-3\", disabled && \"opacity-50\", className)}\n      onDragOver={onDragOver}\n      onDragEnter={onDragEnter}\n      onDragLeave={onDragLeave}\n      onDrop={onDrop}\n    >\n      <input\n        ref={inputRef}\n        type=\"file\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        className=\"sr-only\"\n        tabIndex={-1}\n        aria-hidden\n        onChange={(e) => {\n          if (e.target.files) ingest(e.target.files);\n          e.target.value = \"\"; // allow re-picking the same file\n        }}\n      />\n\n      <button\n        ref={buttonRef}\n        type=\"button\"\n        disabled={disabled}\n        onClick={openPicker}\n        aria-label={typeof label === \"string\" ? label : \"Add files\"}\n        data-drag-active={dragActive || undefined}\n        className={cn(\n          \"group relative isolate flex min-h-[168px] w-full flex-col items-center justify-center gap-3\",\n          \"overflow-hidden rounded-xl border border-dashed px-6 py-8 text-center\",\n          \"border-neutral-700 bg-neutral-900/40\",\n          \"outline-none transition-[transform,border-color,background-color,box-shadow] ease-out\",\n          \"focus-visible:border-transparent\",\n          !disabled && \"cursor-pointer hover:border-neutral-500\",\n          still ? \"duration-0\" : \"duration-300\"\n        )}\n        style={{\n          borderColor: dragActive ? accent : undefined,\n          backgroundColor: dragActive ? `${accent}14` : undefined,\n          transform: !still && dragActive ? \"translateY(-3px) scale(1.005)\" : undefined,\n          boxShadow: dragActive ? `0 18px 40px -24px ${accent}, 0 0 0 1px ${accent}` : undefined,\n          [\"--tw-ring-color\" as string]: accent,\n        }}\n      >\n        {/* Faint coordinate grid — brightens while a drag hovers, static under\n            reduced motion. Decorative. Edges fade via a radial mask. */}\n        <span\n          aria-hidden\n          className={cn(\n            \"pointer-events-none absolute inset-0 -z-10\",\n            still ? \"transition-none\" : \"transition-opacity duration-300 ease-out\"\n          )}\n          style={{\n            backgroundImage:\n              \"linear-gradient(to right, rgba(255,255,255,0.10) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.10) 1px, transparent 1px)\",\n            backgroundSize: \"22px 22px\",\n            opacity: dragActive ? 0.85 : 0.4,\n            maskImage: \"radial-gradient(ellipse at center, black 45%, transparent 90%)\",\n            WebkitMaskImage: \"radial-gradient(ellipse at center, black 45%, transparent 90%)\",\n          }}\n        />\n        {/* Focus ring drawn manually so it can adopt the accent hue. */}\n        <span\n          aria-hidden\n          className=\"pointer-events-none absolute inset-0 rounded-xl ring-2 ring-offset-0 opacity-0 transition-opacity group-focus-visible:opacity-100\"\n          style={{ boxShadow: `inset 0 0 0 2px ${accent}` }}\n        />\n\n        <svg\n          aria-hidden\n          viewBox=\"0 0 24 24\"\n          fill=\"none\"\n          stroke=\"currentColor\"\n          strokeWidth=\"1.6\"\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n          className={cn(\n            \"h-7 w-7 text-neutral-400 transition-transform ease-out\",\n            !still && \"group-data-[drag-active]:-translate-y-0.5\",\n            still ? \"duration-0\" : \"duration-300\"\n          )}\n          style={{ color: dragActive ? accent : undefined }}\n        >\n          <path d=\"M12 15V3\" />\n          <path d=\"m7 8 5-5 5 5\" />\n          <path d=\"M5 15v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4\" />\n        </svg>\n\n        <span className=\"flex flex-col gap-1\">\n          <span className=\"text-sm font-medium text-neutral-200\">{label}</span>\n          {defaultHint && (\n            <span className=\"text-xs text-neutral-500\">{defaultHint}</span>\n          )}\n        </span>\n      </button>\n\n      {/* Always mounted so AnimatePresence can play the exit of the LAST card\n          too (a conditional list would unmount it before the exit runs). */}\n      <ul\n          className=\"flex flex-col gap-2 empty:hidden\"\n          aria-label={`Selected files, ${files.length}`}\n        >\n          <AnimatePresence initial={false}>\n            {files.map(({ id, file }, index) => (\n              <motion.li\n                key={id}\n                ref={(el) => {\n                  if (el) cardRefs.current.set(id, el);\n                  else cardRefs.current.delete(id);\n                }}\n                tabIndex={0}\n                aria-label={`${file.name}, ${formatBytes(file.size)}. Press Delete to remove.`}\n                onKeyDown={(e) => {\n                  if (e.key === \"Delete\" || e.key === \"Backspace\") {\n                    e.preventDefault();\n                    remove(id);\n                  }\n                }}\n                layout={still ? false : true}\n                initial={still ? false : { opacity: 0, y: -8, scale: 0.98 }}\n                animate={{ opacity: 1, y: 0, scale: 1 }}\n                exit={still ? { opacity: 0 } : { opacity: 0, y: -6, scale: 0.98 }}\n                transition={cardTransition}\n                className={cn(\n                  \"group/card relative flex items-center gap-3 rounded-lg px-3 py-2.5\",\n                  \"border border-neutral-800 bg-neutral-900\",\n                  // Soft downward inner-shadow — the card reads as seated.\n                  \"shadow-[inset_0_1px_0_rgba(255,255,255,0.04),inset_0_-6px_12px_-8px_rgba(0,0,0,0.9)]\",\n                  \"outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-950\"\n                )}\n                style={{ [\"--tw-ring-color\" as string]: accent }}\n              >\n                {renderCard ? (\n                  renderCard({ file, id, index, remove: () => remove(id) })\n                ) : (\n                  <>\n                    <span\n                      aria-hidden\n                      className=\"flex h-9 w-9 shrink-0 items-center justify-center rounded-md border border-neutral-800 bg-neutral-950 text-[9px] font-semibold tracking-wide text-neutral-400\"\n                    >\n                      {extensionOf(file.name)}\n                    </span>\n                    <span className=\"flex min-w-0 flex-1 flex-col\">\n                      <span className=\"truncate text-sm text-neutral-200\">{file.name}</span>\n                      <span className=\"truncate text-xs text-neutral-500\">\n                        {formatBytes(file.size)}\n                        {file.type ? ` · ${file.type}` : \"\"}\n                      </span>\n                      {renderProgress?.({ file, id })}\n                    </span>\n                    <button\n                      type=\"button\"\n                      // -1: the whole card is the focus target; the card's Delete\n                      // key removes it, so this button stays out of the tab order.\n                      tabIndex={-1}\n                      aria-label={`Remove ${file.name}`}\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        remove(id);\n                      }}\n                      className={cn(\n                        \"flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-neutral-500\",\n                        \"transition-colors hover:bg-neutral-800 hover:text-neutral-200\"\n                      )}\n                    >\n                      <svg\n                        aria-hidden\n                        viewBox=\"0 0 24 24\"\n                        fill=\"none\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"1.8\"\n                        strokeLinecap=\"round\"\n                        strokeLinejoin=\"round\"\n                        className=\"h-4 w-4\"\n                      >\n                        <path d=\"M18 6 6 18\" />\n                        <path d=\"m6 6 12 12\" />\n                      </svg>\n                    </button>\n                  </>\n                )}\n              </motion.li>\n            ))}\n          </AnimatePresence>\n        </ul>\n    </div>\n  );\n}\n"
    }
  ]
}