{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropzone",
  "title": "Dropzone",
  "description": "Files, dropped or chosen. The area is only its corner marks, and files carried over it close them in; the sentence is a real file input, and what you hold is listed as attachments.",
  "registryDependencies": [
    "https://thedirectors.agency/ui/r/0nlytype.json",
    "https://thedirectors.agency/ui/r/attachment.json"
  ],
  "files": [
    {
      "path": "registry/0nlytype/ui/dropzone.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { roll } from \"@/lib/0nlytype/roll\"\nimport { cn } from \"@/lib/utils\"\nimport { Attachment, AttachmentList } from \"@/components/ui/attachment\"\n\ntype DropzoneProps = Omit<React.ComponentProps<\"div\">, \"onChange\"> & {\n  /** The file input's accept: \".pdf,image/*\". A file that doesn't match is refused and says why. */\n  accept?: string\n  /** Largest file, in bytes. */\n  maxSize?: number\n  multiple?: boolean\n  disabled?: boolean\n  /** Submits the files with a form: the real input holds exactly what's listed. */\n  name?: string\n  /** The words that start the sentence, before \", or choose\". */\n  prompt?: string\n  /** What it takes, in pencil under the words: \"PDF or PNG, up to 10 MB each.\" */\n  hint?: React.ReactNode\n  /** Files held from the start, such as a draft's. */\n  defaultFiles?: File[]\n  /** Called with every file held, after each drop, choice, removal or form reset. */\n  onFilesChange?: (files: File[]) => void\n  /** Lists the files held as attachment rows (the default). Turn it off to list them yourself, with their progress. */\n  list?: boolean\n  /** corners: the corner marks close in as files come over. ghost: the number of files you carry stands huge and faint behind the words. */\n  variant?: \"corners\" | \"ghost\"\n  /** Pins a state for documentation (\"hover\", \"focus\", \"over\"); set on the root. */\n  \"data-force\"?: string\n}\n\ntype Refused = { file: File; why: string }\n\nconst megabytes = (n: number) =>\n  n < 1e3 ? `${n} B` : new Intl.NumberFormat(\"en\", { style: \"unit\", unit: n < 1e6 ? \"kilobyte\" : \"megabyte\", maximumFractionDigits: 1 }).format(n < 1e6 ? n / 1e3 : n / 1e6)\n\n/** Whether a file matches the input's accept list: extensions, exact types and type/* families. */\nfunction accepts(file: File, accept?: string) {\n  if (!accept?.trim()) return true\n  return accept.split(\",\").some((rule) => {\n    const r = rule.trim().toLowerCase()\n    if (r.startsWith(\".\")) return file.name.toLowerCase().endsWith(r)\n    if (r.endsWith(\"/*\")) return file.type.toLowerCase().startsWith(r.slice(0, -1))\n    return file.type.toLowerCase() === r\n  })\n}\n\n/** \"PDF or PNG\" from \".pdf,.png\"; \"image files\" from \"image/*\". */\nconst kinds = (accept: string) => {\n  const names = accept.split(\",\").map((r) => r.trim()).map((r) => (r.startsWith(\".\") ? r.slice(1).toUpperCase() : r.endsWith(\"/*\") ? `${r.slice(0, -2)} files` : r))\n  return names.length > 1 ? `${names.slice(0, -1).join(\", \")} or ${names.at(-1)}` : names[0]\n}\n\n/**\n * Files, dropped or chosen. The area is only its corner marks; files carried over it close them in. The sentence\n * \"Drop files here, or choose\" is a real file input: Tab reaches it and Enter or Space opens the picker. What's held\n * is listed as attachments; a refused file says what to fix.\n */\nfunction Dropzone({\n  accept,\n  maxSize,\n  multiple = true,\n  disabled,\n  name,\n  prompt = \"Drop files here\",\n  hint,\n  defaultFiles,\n  onFilesChange,\n  list = true,\n  variant = \"corners\",\n  className,\n  \"data-force\": force,\n  ...props\n}: DropzoneProps) {\n  const input = React.useRef<HTMLInputElement>(null)\n  const ghost = React.useRef<HTMLSpanElement>(null)\n  const depth = React.useRef(0)\n  const id = React.useId()\n  const [files, setFiles] = React.useState<File[]>(defaultFiles ?? [])\n  const [refused, setRefused] = React.useState<Refused[]>([])\n  const [carried, setCarried] = React.useState(0) // files over the area right now; 0 when none\n  const [focused, setFocused] = React.useState(false)\n\n  /** Keeps the real input holding exactly the listed files, so a form sends what you see. */\n  const hold = React.useCallback((next: File[]) => {\n    setFiles(next)\n    onFilesChange?.(next)\n    if (input.current && typeof DataTransfer !== \"undefined\") {\n      const t = new DataTransfer()\n      next.forEach((f) => t.items.add(f))\n      input.current.files = t.files\n    }\n  }, [onFilesChange])\n\n  // The real input starts out holding what's listed, too.\n  React.useEffect(() => {\n    if (defaultFiles?.length && input.current && typeof DataTransfer !== \"undefined\") {\n      const t = new DataTransfer()\n      defaultFiles.forEach((f) => t.items.add(f))\n      input.current.files = t.files\n    }\n  }, []) // eslint-disable-line react-hooks/exhaustive-deps -- only the first files\n\n  // Resetting the form empties what's listed as well as the real input.\n  React.useEffect(() => {\n    const form = input.current?.form\n    if (!form) return\n    const reset = () => {\n      hold([])\n      setRefused([])\n      depth.current = 0\n      setCarried(0)\n    }\n    form.addEventListener(\"reset\", reset)\n    return () => form.removeEventListener(\"reset\", reset)\n  }, [hold])\n\n  const take = (incoming: File[]) => {\n    const ok: File[] = []\n    const no: Refused[] = []\n    for (const file of multiple ? incoming : incoming.slice(0, 1)) {\n      if (!accepts(file, accept)) no.push({ file, why: `This takes ${kinds(accept!)}. Choose another file.` })\n      else if (maxSize !== undefined && file.size > maxSize) no.push({ file, why: `Choose one under ${megabytes(maxSize)}; this is ${megabytes(file.size)}.` })\n      else ok.push(file)\n    }\n    const same = (a: File, b: File) => a.name === b.name && a.size === b.size && a.lastModified === b.lastModified\n    hold(multiple ? [...files.filter((f) => !ok.some((o) => same(o, f))), ...ok] : ok.length ? ok : files)\n    setRefused(no)\n  }\n\n  // ghost: the count turns over as files arrive over the area, up as they come and down as they go.\n  const shown = carried || files.length\n  const [count, setCount] = React.useState(shown)\n  const was = React.useRef(shown)\n  React.useEffect(() => {\n    const before = was.current\n    was.current = shown\n    if (before === shown || !ghost.current || variant !== \"ghost\") return setCount(shown)\n    roll(ghost.current, () => setCount(shown), \"0.2em\", shown < before ? -1 : 1)\n  }, [shown, variant])\n\n  const over = carried > 0\n  return (\n    <div\n      data-slot=\"dropzone\"\n      data-variant={variant}\n      data-over={over ? \"\" : undefined}\n      data-disabled={disabled ? \"\" : undefined}\n      data-force={force}\n      className={cn(\"ot-drop\", className)}\n      {...props}\n    >\n      <label\n        className=\"ot-drop-zone ot-corners\"\n        onDragEnter={(e) => {\n          if (disabled || !e.dataTransfer.types.includes(\"Files\")) return\n          e.preventDefault()\n          depth.current++\n          setCarried(e.dataTransfer.items.length || 1)\n        }}\n        onDragOver={(e) => {\n          if (disabled || !e.dataTransfer.types.includes(\"Files\")) return\n          e.preventDefault()\n          e.dataTransfer.dropEffect = \"copy\"\n        }}\n        onDragLeave={() => {\n          if (--depth.current <= 0) {\n            depth.current = 0\n            setCarried(0)\n          }\n        }}\n        onDrop={(e) => {\n          if (disabled) return\n          e.preventDefault()\n          depth.current = 0\n          setCarried(0)\n          take(Array.from(e.dataTransfer.files))\n        }}\n      >\n        <input\n          ref={input}\n          type=\"file\"\n          className=\"ot-sr\"\n          accept={accept}\n          multiple={multiple}\n          disabled={disabled}\n          name={name}\n          aria-describedby={hint ? `${id}-hint` : undefined}\n          onFocus={(e) => setFocused(e.currentTarget.matches(\":focus-visible\"))}\n          onBlur={() => setFocused(false)}\n          onChange={(e) => take(Array.from(e.currentTarget.files ?? []))}\n        />\n        {variant === \"ghost\" ? (\n          <span ref={ghost} className=\"ot-drop-ghost\" aria-hidden=\"true\" data-none={count === 0 ? \"\" : undefined}>\n            {String(count).padStart(2, \"0\")}\n          </span>\n        ) : null}\n        <span className=\"ot-drop-words\">\n          {prompt}, or{\" \"}\n          <span className=\"ot-link\" data-state={focused || force?.includes(\"focus\") ? \"open\" : undefined}>\n            choose\n          </span>\n        </span>\n        {hint ? (\n          <span id={`${id}-hint`} className=\"ot-drop-hint\">\n            {hint}\n          </span>\n        ) : null}\n      </label>\n      {list && (files.length || refused.length) ? (\n        <AttachmentList className=\"ot-drop-list\" aria-live=\"polite\">\n          {files.map((f) => (\n            <Attachment key={`${f.name}${f.size}${f.lastModified}`} name={f.name} size={megabytes(f.size)} state=\"idle\" onRemove={() => hold(files.filter((x) => x !== f))} />\n          ))}\n          {refused.map((r) => (\n            <Attachment\n              key={`no${r.file.name}${r.file.size}`}\n              name={r.file.name}\n              status={r.why}\n              state=\"error\"\n              onRemove={() => setRefused((all) => all.filter((x) => x !== r))}\n            />\n          ))}\n        </AttachmentList>\n      ) : null}\n    </div>\n  )\n}\n\nexport { Dropzone, type DropzoneProps }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/0nlytype/styles/dropzone.css",
      "content": "@layer components {\n  /* ── ot-drop ── files, dropped or chosen. The area is only its corner marks, after \"the silence that heals\": a frame that\n     doesn't close. Pointing sketches them in pencil; files carried over close them in and ink them, landing with spiccato;\n     focus sets them in the accent. The sentence is the control, a real file input under \"choose\". */\n  @property --ot-drop-c { syntax: \"<color>\"; inherits: true; initial-value: transparent; }\n  .ot-drop { display: grid; gap: var(--ot-space-5); }\n  .ot-drop[data-force~=\"focus\"] { outline: 0; }\n  .ot-drop-zone {\n    --ot-drop-c: var(--ot-rule-strong); --ot-corner: var(--ot-space-4); --ot-corner-colour: var(--ot-drop-c); --ot-corner-inset: 0px;\n    position: relative; isolation: isolate; overflow: clip; display: grid; place-content: center; justify-items: center; gap: var(--ot-space-2);\n    min-block-size: calc(var(--ot-space-9) + var(--ot-space-5)); padding: var(--ot-space-6) var(--ot-space-5); text-align: center; cursor: pointer;\n    transition: --ot-drop-c var(--ot-moderato) var(--ot-exhale);\n  }\n  .ot-drop-zone::after { transition: inset var(--ot-andante) var(--ot-spiccato); }\n  .ot-drop-zone:hover, .ot-drop[data-force~=\"hover\"] > .ot-drop-zone { --ot-drop-c: var(--ot-pencil); }\n  .ot-drop-zone:has(> input:focus-visible), .ot-drop[data-force~=\"focus\"] > .ot-drop-zone { --ot-drop-c: var(--ot-accent); }\n  .ot-drop:is([data-over], [data-force~=\"over\"]) > .ot-drop-zone { --ot-drop-c: var(--ot-ink); --ot-corner-inset: var(--ot-space-4); }\n  .ot-drop-words { font-size: var(--ot-mp); line-height: var(--ot-mp-lh); color: var(--ot-graphite); transition: color var(--ot-moderato); }\n  .ot-drop:is([data-over], [data-force~=\"over\"]) .ot-drop-words { color: var(--ot-ink); }\n  .ot-drop-hint { max-width: 40ch; font-size: var(--ot-pp); line-height: var(--ot-pp-lh); color: var(--ot-pencil); }\n  .ot-drop[data-disabled] > .ot-drop-zone { --ot-drop-c: var(--ot-rule); cursor: not-allowed; }\n  .ot-drop[data-disabled] :is(.ot-drop-words, .ot-drop-words .ot-link) { color: var(--ot-pencil); }\n\n  /* ── ghost ── after the ghost numerals behind \"the silence that heals\": the number of files you carry stands huge and faint\n     behind the words, cropped by the area's foot and end, and stays as the number held. It turns over as the count moves. */\n  .ot-drop-ghost {\n    position: absolute; z-index: -1; inset-inline-end: var(--ot-space-3); bottom: -0.2em; pointer-events: none;\n    font-family: var(--ot-expression); font-style: italic; font-size: calc(var(--ot-fff) * var(--ot-expression-scale)); line-height: 0.8; letter-spacing: var(--ot-fff-tr);\n    font-variant-numeric: lining-nums tabular-nums; color: color-mix(in srgb, var(--ot-ink) 8%, transparent);\n    transition: opacity var(--ot-andante) var(--ot-exhale), color var(--ot-moderato);\n  }\n  .ot-drop-ghost[data-none] { opacity: 0; }\n  .ot-drop:is([data-over], [data-force~=\"over\"]) .ot-drop-ghost { color: color-mix(in srgb, var(--ot-ink) 14%, transparent); }\n\n  @media (forced-colors: active) {\n    .ot-drop-zone { forced-color-adjust: none; --ot-drop-c: GrayText; color: CanvasText; }\n    .ot-drop-zone:has(> input:focus-visible) { --ot-drop-c: Highlight; }\n    .ot-drop[data-over] > .ot-drop-zone { --ot-drop-c: CanvasText; }\n    .ot-drop-ghost { display: none; }\n  }\n}\n",
      "type": "registry:file",
      "target": "styles/0nlytype/dropzone.css"
    }
  ],
  "css": {
    "@import \"../styles/0nlytype/dropzone.css\"": {}
  },
  "meta": {
    "movement": "VI",
    "underneath": "native",
    "contract": "ot-drop"
  },
  "type": "registry:ui"
}
