{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "drop-zone",
  "type": "registry:ui",
  "title": "Drop zone",
  "description": "A place that receives files, by drop or by picking: a sunk tray whose edge lights while files are dragged in the window, sinks and says 'Let go to attach' with files over it, refuses a file it won't take with a shake, and comes back up on the object spring when they land.",
  "dependencies": [],
  "registryDependencies": [
    "https://metalui.dev/r/tokens.json"
  ],
  "files": [
    {
      "path": "packages/metalui/src/components/drop-zone/drop-zone.tsx",
      "type": "registry:ui",
      "target": "components/metalui/drop-zone/drop-zone.tsx",
      "content": "'use client';\n\nimport * as React from 'react';\nimport { SwapText } from '../../motion/swap';\nimport { refuse } from '../../motion/refuse';\n\n/* ─────────────────────────────────────────────────────────\n * DROP ZONE, a place that receives files\n *\n *   rest      a sunk tray: a glyph in a small well, \"Drop files here\", what it takes, \"or choose\n *             files\"; the tray is the label of a real file input, so a click, Space or Enter picks\n *   armed     files dragged anywhere in the window: the edge lights faintly green (settle spring),\n *             so the eye finds where to drop\n *   over      files over the tray: the edge lights fully, the tray sinks a touch and the glyph rises\n *             one step (part spring); the line turns \"Let go to attach\" on the drum\n *   refused   over it with a file it won't take: the edge in the invalid ink, the line says so;\n *             dropping shakes it (refusal)\n *   drop      the tray comes back up on the object spring (its small overshoot); onFiles gets what\n *             it took, and those land below as attachments\n *   disabled  dimmed; drags pass by with no drop effect; the picker does not open\n *   compact   one row, for a composer\n * Reduce Motion: the edge and line change at once; nothing sinks, rises or shakes.\n * A place: it receives files and holds nothing itself. It uses the well, the drum and the refusal.\n * ───────────────────────────────────────────────────────── */\n\nconst ROOT = 'mu-drop-zone relative grid content-center justify-items-center gap-drop-zone-gap min-h-drop-zone-min-height p-drop-zone-pad rounded-drop-zone-radius recipe-well-field text-center cursor-pointer select-none drop-zone-edge drop-zone-sink drop-zone-disabled has-focus-visible:focus-ring';\nconst COMPACT = 'mu-drop-zone relative flex items-center gap-drop-zone-compact-gap h-drop-zone-compact-height px-drop-zone-compact-pad-x rounded-drop-zone-radius recipe-well-field text-left cursor-pointer select-none drop-zone-edge drop-zone-sink drop-zone-disabled has-focus-visible:focus-ring';\nconst WELL = 'mu-drop-zone-well grid flex-none place-items-center size-drop-zone-well-size rounded-drop-zone-well-radius recipe-surface-raise-sm text-ink2 drop-zone-rise [&>svg]:size-drop-zone-well-glyph';\nconst TITLE = 'mu-drop-zone-title type-ui text-ink';\nconst LINE = 'mu-drop-zone-description type-meta text-ink3';\nconst CHOOSE = 'mu-drop-zone-choose type-meta text-ink2 underline underline-offset-2';\nconst WORDS = 'mu-drop-zone-words grid min-w-0 gap-1';\n\nexport type DropRefusal = { file: File; reason: 'type' | 'size' | 'count' };\n\nexport interface DropZoneProps {\n  /** Receives the files it took, and the ones it refused with why. */\n  onFiles: (files: File[], refused: DropRefusal[]) => void;\n  /** What it takes, as the file input's accept: \"image/*,.pdf\". Everything by default. */\n  accept?: string;\n  /** Largest file it takes, in bytes. */\n  maxSize?: number;\n  /** More than one file at a time (the default). */\n  multiple?: boolean;\n  disabled?: boolean;\n  /** The first line: \"Drop files here\". Also the input's name. */\n  title?: string;\n  /** What it takes, in words: \"PDFs and images, up to 10 MB\". */\n  description?: string;\n  /** The line while files are over it. */\n  overTitle?: string;\n  /** The line while a file it won't take is over it. */\n  refusedTitle?: string;\n  /** The words that say it can be clicked. */\n  chooseLabel?: string;\n  /** A glyph from the icon set, in the well. */\n  icon?: React.ReactNode;\n  /** One row, for a composer. */\n  compact?: boolean;\n  className?: string;\n}\n\nfunction patterns(accept?: string) {\n  return (accept ?? '').split(',').map((p) => p.trim().toLowerCase()).filter(Boolean);\n}\n\n/** Whether a MIME type (and a name, when known) fits the accept list. Names are unknown while dragging. */\nfunction fits(list: string[], type: string, name?: string) {\n  if (list.length === 0) return true;\n  const t = type.toLowerCase();\n  return list.some((p) => {\n    if (p.startsWith('.')) return name == null ? true : name.toLowerCase().endsWith(p);\n    if (p.endsWith('/*')) return t.startsWith(p.slice(0, -1));\n    return t === p;\n  });\n}\n\nfunction hasFiles(e: DragEvent | React.DragEvent) {\n  return Array.from(e.dataTransfer?.types ?? []).includes('Files');\n}\n\n/** A place that receives files, by drop or by picking. */\nexport function DropZone({\n  onFiles, accept, maxSize, multiple = true, disabled, compact, icon, className,\n  title = 'Drop files here', description, overTitle = 'Let go to attach', refusedTitle = 'This file isn’t taken here', chooseLabel = 'or choose files',\n}: DropZoneProps) {\n  const root = React.useRef<HTMLLabelElement>(null);\n  const input = React.useRef<HTMLInputElement>(null);\n  const [armed, setArmed] = React.useState(false);\n  const [over, setOver] = React.useState(false);\n  const [refused, setRefused] = React.useState(false);\n  const descId = React.useId();\n  const list = React.useMemo(() => patterns(accept), [accept]);\n\n  // Armed while files are dragged anywhere in the window; dragover repeats, so silence ends it.\n  // A drop that misses the zone is swallowed, so the browser never navigates to the file.\n  React.useEffect(() => {\n    if (disabled) return;\n    let quiet = 0;\n    const onOver = (e: DragEvent) => {\n      if (!hasFiles(e)) return;\n      if (!root.current?.contains(e.target as Node)) { e.preventDefault(); if (e.dataTransfer) e.dataTransfer.dropEffect = 'none'; }\n      setArmed(true);\n      window.clearTimeout(quiet);\n      quiet = window.setTimeout(() => { setArmed(false); setOver(false); }, 150);\n    };\n    const onDrop = (e: DragEvent) => {\n      if (!hasFiles(e)) return;\n      if (!root.current?.contains(e.target as Node)) e.preventDefault();\n      window.clearTimeout(quiet);\n      setArmed(false);\n    };\n    window.addEventListener('dragover', onOver);\n    window.addEventListener('drop', onDrop);\n    return () => { window.clearTimeout(quiet); window.removeEventListener('dragover', onOver); window.removeEventListener('drop', onDrop); };\n  }, [disabled]);\n\n  const take = (all: File[]) => {\n    const files: File[] = [];\n    const no: DropRefusal[] = [];\n    for (const file of all) {\n      if (!fits(list, file.type, file.name)) no.push({ file, reason: 'type' });\n      else if (maxSize != null && file.size > maxSize) no.push({ file, reason: 'size' });\n      else if (!multiple && files.length > 0) no.push({ file, reason: 'count' });\n      else files.push(file);\n    }\n    if (no.length > 0) refuse(root.current);\n    if (all.length > 0) onFiles(files, no);\n  };\n\n  const onDragOver = (e: React.DragEvent) => {\n    if (disabled || !hasFiles(e)) return;\n    e.preventDefault();\n    const items = Array.from(e.dataTransfer.items).filter((i) => i.kind === 'file');\n    const no = items.some((i) => !fits(list, i.type)) || (!multiple && items.length > 1);\n    e.dataTransfer.dropEffect = no ? 'none' : 'copy';\n    setOver(true);\n    setRefused(no);\n  };\n  const onDragLeave = (e: React.DragEvent) => {\n    if (root.current?.contains(e.relatedTarget as Node)) return;\n    setOver(false);\n    setRefused(false);\n  };\n  const onDrop = (e: React.DragEvent) => {\n    if (disabled || !hasFiles(e)) return;\n    e.preventDefault();\n    setOver(false);\n    setRefused(false);\n    setArmed(false);\n    take(Array.from(e.dataTransfer.files));\n  };\n  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {\n    take(Array.from(e.target.files ?? []));\n    e.target.value = '';\n  };\n\n  const line = refused ? refusedTitle : over ? overTitle : title;\n  const base = compact ? COMPACT : ROOT;\n  return (\n    <label\n      ref={root}\n      data-armed={armed && !disabled ? '' : undefined}\n      data-over={over ? '' : undefined}\n      data-refused={refused ? '' : undefined}\n      data-disabled={disabled ? '' : undefined}\n      className={className ? `${base} ${className}` : base}\n      onDragOver={onDragOver}\n      onDragLeave={onDragLeave}\n      onDrop={onDrop}\n    >\n      <input\n        ref={input}\n        type=\"file\"\n        className=\"sr-only\"\n        accept={accept}\n        multiple={multiple}\n        disabled={disabled}\n        aria-label={title}\n        aria-describedby={description ? descId : undefined}\n        onChange={onChange}\n      />\n      {icon && <span aria-hidden className={WELL}>{icon}</span>}\n      <span className={WORDS}>\n        <span aria-hidden className={TITLE}><SwapText value={line} /></span>\n        {description && <span id={descId} className={LINE}>{description}</span>}\n      </span>\n      {!disabled && <span aria-hidden className={compact ? `${CHOOSE} ms-auto flex-none` : CHOOSE}>{chooseLabel}</span>}\n    </label>\n  );\n}\n"
    }
  ],
  "docs": "Agent guide: https://metalui.dev/r/drop-zone.md. SwiftUI: MetalDropZone in the MetalUI Swift package."
}
