{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "dropzone",
  "type": "registry:ui",
  "title": "Dropzone",
  "description": "Lights up when files cross the window, seals its dashes, names every rejection.",
  "categories": [
    "forms"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/dropzone.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useId, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ease, spring } from \"@/lib/motion\";\n\n/* ------------------------------------------------------------------ */\n/* Rules and messages: usable without the component                    */\n/* ------------------------------------------------------------------ */\n\nexport type FileRejectionReason = \"type\" | \"size\" | \"empty\" | \"count\";\nexport type FileRejection = { file: File; reason: FileRejectionReason; message: string };\n\nexport type DropzoneRules = {\n  /** MIME types, wildcards and extensions, as in the input's accept attribute: \"image/*\", \"application/pdf\", \".csv\". */\n  accept?: string | string[];\n  /** Largest file allowed, in bytes. */\n  maxSize?: number;\n  /** Most files taken from one drop, pick or paste. The rest are rejected by name. */\n  maxFiles?: number;\n  /** Allow more than one file at a time. */\n  multiple?: boolean;\n};\n\nconst tokensOf = (accept?: string | string[]) =>\n  (Array.isArray(accept) ? accept : (accept ?? \"\").split(\",\")).map((t) => t.trim().toLowerCase()).filter(Boolean);\n\n// Extensions whose MIME type is predictable, so a drag can be judged before the drop.\nconst extMime: Record<string, string> = {\n  \".png\": \"image/png\", \".jpg\": \"image/jpeg\", \".jpeg\": \"image/jpeg\", \".gif\": \"image/gif\", \".webp\": \"image/webp\",\n  \".svg\": \"image/svg+xml\", \".heic\": \"image/heic\", \".pdf\": \"application/pdf\", \".csv\": \"text/csv\", \".txt\": \"text/plain\",\n  \".json\": \"application/json\", \".zip\": \"application/zip\", \".mp4\": \"video/mp4\", \".mov\": \"video/quicktime\", \".mp3\": \"audio/mpeg\",\n};\n\nfunction mimeMatches(token: string, type: string) {\n  if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1));\n  return type === token;\n}\n\nfunction fileMatches(file: { name: string; type: string }, tokens: string[]) {\n  if (!tokens.length) return true;\n  const name = file.name.toLowerCase();\n  const type = (file.type || \"\").toLowerCase();\n  return tokens.some((t) => (t.startsWith(\".\") ? name.endsWith(t) : mimeMatches(t, type)));\n}\n\n/** A drag only exposes MIME types, not names or sizes, so it can say yes, no, or can't tell. */\nfunction judgeDrag(types: string[], tokens: string[]): \"ok\" | \"reject\" | \"unknown\" {\n  if (!tokens.length) return \"ok\";\n  if (!types.length) return \"unknown\";\n  let unknown = false;\n  for (const raw of types) {\n    const type = raw.toLowerCase();\n    if (!type) { unknown = true; continue; }\n    for (const t of tokens) {\n      if (t.startsWith(\".\")) {\n        if (!extMime[t]) unknown = true;\n        else if (extMime[t] === type) return \"ok\";\n      } else if (mimeMatches(t, type)) return \"ok\";\n    }\n  }\n  return unknown ? \"unknown\" : \"reject\";\n}\n\n/** 10485760 → \"10 MB\". Binary units, the way file managers count them; a no-break space keeps \"10 MB\" on one line. */\nexport function formatBytes(bytes: number) {\n  if (bytes < 1024) return `${bytes}\\u00a0B`;\n  const units = [\"KB\", \"MB\", \"GB\", \"TB\"];\n  let v = bytes / 1024;\n  let i = 0;\n  while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }\n  return `${v >= 10 || Number.isInteger(v) ? Math.round(v) : v.toFixed(1)}\\u00a0${units[i]}`;\n}\n\n/** Keeps the extension visible: \"q3-forecast-final-v7-approved.xlsx\" → \"q3-forecast-fi…approved.xlsx\". */\nexport function middleTruncate(name: string, max = 28) {\n  if (name.length <= max) return name;\n  const dot = name.lastIndexOf(\".\");\n  const tail = Math.max(8, dot > 0 ? name.length - dot + 4 : 8);\n  return `${name.slice(0, max - tail - 1)}…${name.slice(-tail)}`;\n}\n\nconst labelFor = (t: string) => {\n  if (t === \"image/*\") return \"images\";\n  if (t === \"video/*\") return \"videos\";\n  if (t === \"audio/*\") return \"audio\";\n  if (t === \"text/*\") return \"text files\";\n  if (t === \"image/jpeg\" || t === \".jpg\" || t === \".jpeg\") return \"JPG\";\n  if (t === \"image/svg+xml\") return \"SVG\";\n  if (t.includes(\"spreadsheetml\")) return \"XLSX\";\n  if (t.includes(\"wordprocessingml\")) return \"DOCX\";\n  return (t.startsWith(\".\") ? t.slice(1) : t.split(\"/\")[1] ?? t).toUpperCase();\n};\n\n/** \"image/*, .pdf\" → \"Images or PDF\". Duplicates (jpg, jpeg, image/jpeg) collapse into one word. */\nexport function describeAccept(accept?: string | string[]) {\n  const words = [...new Set(tokensOf(accept).map(labelFor))];\n  if (!words.length) return \"\";\n  const list = new Intl.ListFormat(\"en\", { type: \"disjunction\" }).format(words);\n  return list.charAt(0).toUpperCase() + list.slice(1);\n}\n\nconst plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;\n\n/** Splits files into accepted and rejected, each rejection with a sentence that names the file. */\nexport function validateFiles(files: File[], rules: DropzoneRules = {}) {\n  const tokens = tokensOf(rules.accept);\n  const limit = rules.multiple === false ? 1 : rules.maxFiles;\n  const accepted: File[] = [];\n  const rejected: FileRejection[] = [];\n  for (const file of files) {\n    const short = middleTruncate(file.name);\n    if (!fileMatches(file, tokens)) {\n      const kinds = describeAccept(rules.accept);\n      rejected.push({ file, reason: \"type\", message: `${short} isn’t a supported type. Use ${kinds}.` });\n    } else if (file.size === 0) {\n      rejected.push({ file, reason: \"empty\", message: `${short} is empty. Choose another file.` });\n    } else if (rules.maxSize != null && file.size > rules.maxSize) {\n      rejected.push({ file, reason: \"size\", message: `${short} is ${formatBytes(file.size)}. The limit is ${formatBytes(rules.maxSize)}.` });\n    } else if (limit != null && accepted.length >= limit) {\n      rejected.push({ file, reason: \"count\", message: `${limit === 1 ? \"One file\" : `${limit} files`} at a time. ${short} wasn’t added.` });\n    } else accepted.push(file);\n  }\n  return { accepted, rejected };\n}\n\n/** One line for any mix of rejections. A single file gets its own sentence; several get a tally. */\nexport function summarizeRejections(rejected: FileRejection[], rules: DropzoneRules = {}) {\n  if (!rejected.length) return \"\";\n  if (rejected.length === 1) return rejected[0].message;\n  const count = (r: FileRejectionReason) => rejected.filter((x) => x.reason === r).length;\n  const parts = [\n    count(\"type\") && `${count(\"type\")} unsupported`,\n    count(\"size\") && `${count(\"size\")} over ${formatBytes(rules.maxSize ?? 0)}`,\n    count(\"empty\") && `${count(\"empty\")} empty`,\n    count(\"count\") && `${count(\"count\")} over the ${rules.multiple === false ? 1 : rules.maxFiles}-file limit`,\n  ].filter(Boolean);\n  return `${rejected.length} files weren’t added: ${parts.join(\", \")}.`;\n}\n\n/* ------------------------------------------------------------------ */\n/* The component                                                       */\n/* ------------------------------------------------------------------ */\n\ntype Phase = \"idle\" | \"armed\" | \"over\" | \"reject\" | \"added\" | \"disabled\";\n\nexport type DropzoneProps = Omit<React.ComponentProps<\"div\">, \"title\" | \"children\"> &\n  DropzoneRules & {\n    /** Called with the files that passed the rules. */\n    onFilesAccepted?: (files: File[]) => void;\n    /** Called with every file that didn't, each with a reason and a message. */\n    onFilesRejected?: (rejections: FileRejection[]) => void;\n    /** Where ⌘V/Ctrl+V pastes land: anywhere on the page (ignored while typing in a field), only while the zone is focused, or nowhere. */\n    paste?: \"document\" | \"focus\" | false;\n    size?: \"sm\" | \"md\";\n    disabled?: boolean;\n    /** A message from the form, such as \"Attach at least one receipt\". Shown in place of the hint and marks the zone invalid. */\n    error?: React.ReactNode;\n    /** Replaces the headline. */\n    title?: React.ReactNode;\n    /** Replaces the generated \"PNG or PDF, up to 10 MB\" line. */\n    hint?: React.ReactNode;\n    /** Accessible name of the zone's button. */\n    label?: string;\n    /** Text of the button drawn at the end of the compact size, e.g. \"Replace\" once a file is in. */\n    browseLabel?: string;\n  };\n\nexport function Dropzone({\n  accept,\n  maxSize,\n  maxFiles,\n  multiple = true,\n  onFilesAccepted,\n  onFilesRejected,\n  paste = \"document\",\n  size = \"md\",\n  disabled = false,\n  error,\n  title,\n  hint,\n  label = \"Choose files\",\n  browseLabel = \"Browse\",\n  className,\n  onKeyDown,\n  ...rest\n}: DropzoneProps) {\n  const reduce = useReducedMotion();\n  const id = useId();\n  const inputRef = useRef<HTMLInputElement>(null);\n  const rootRef = useRef<HTMLDivElement>(null);\n  const zoneDepth = useRef(0);\n  const addedTimer = useRef<number>(undefined);\n\n  const [armed, setArmed] = useState(false);\n  const [over, setOver] = useState(false);\n  const [preview, setPreview] = useState<{ count: number; verdict: \"ok\" | \"reject\" | \"unknown\" }>({ count: 0, verdict: \"unknown\" });\n  const [added, setAdded] = useState(0);\n  const [rejection, setRejection] = useState(\"\");\n  const [announce, setAnnounce] = useState({ text: \"\", n: 0 });\n\n  const tokens = tokensOf(accept);\n  const kinds = describeAccept(accept);\n  const tooMany = !multiple && preview.count > 1;\n  const phase: Phase = disabled\n    ? \"disabled\"\n    : over\n      ? preview.verdict === \"reject\" || tooMany\n        ? \"reject\"\n        : \"over\"\n      : armed\n        ? \"armed\"\n        : added\n          ? \"added\"\n          : \"idle\";\n  const message = error || rejection || null;\n  const invalid = message != null && phase !== \"over\" && phase !== \"armed\";\n\n  useEffect(() => () => window.clearTimeout(addedTimer.current), []);\n\n  const handle = useCallback(\n    (files: File[]) => {\n      if (!files.length) return;\n      const { accepted, rejected } = validateFiles(files, { accept, maxSize, maxFiles, multiple });\n      if (accepted.length) onFilesAccepted?.(accepted);\n      if (rejected.length) onFilesRejected?.(rejected);\n      const why = summarizeRejections(rejected, { accept, maxSize, maxFiles, multiple });\n      setRejection(why);\n      window.clearTimeout(addedTimer.current);\n      setAdded(accepted.length);\n      if (accepted.length) addedTimer.current = window.setTimeout(() => setAdded(0), 1800);\n      const said = [accepted.length ? `${plural(accepted.length, \"file\")} added.` : \"\", why].filter(Boolean).join(\" \");\n      setAnnounce((a) => ({ text: said, n: a.n + 1 }));\n    },\n    [accept, maxSize, maxFiles, multiple, onFilesAccepted, onFilesRejected],\n  );\n\n  // Files dragged anywhere over the window light the zone up, so people can see where to aim.\n  // A drop that misses the zone is swallowed instead of opening the file in the tab and losing the form.\n  useEffect(() => {\n    if (disabled) return;\n    let depth = 0;\n    const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes(\"Files\");\n    const enter = (e: DragEvent) => {\n      if (!hasFiles(e)) return;\n      depth++;\n      setArmed(true);\n    };\n    const leave = (e: DragEvent) => {\n      if (!hasFiles(e)) return;\n      depth = Math.max(0, depth - 1);\n      if (depth === 0) setArmed(false);\n    };\n    const overWindow = (e: DragEvent) => {\n      if (!hasFiles(e) || e.defaultPrevented) return;\n      e.preventDefault();\n      if (e.dataTransfer) e.dataTransfer.dropEffect = \"none\";\n    };\n    const reset = (e: DragEvent) => {\n      depth = 0;\n      setArmed(false);\n      if (e.type === \"drop\" && hasFiles(e) && !e.defaultPrevented) e.preventDefault();\n    };\n    window.addEventListener(\"dragenter\", enter);\n    window.addEventListener(\"dragleave\", leave);\n    window.addEventListener(\"dragover\", overWindow);\n    window.addEventListener(\"drop\", reset);\n    window.addEventListener(\"dragend\", reset);\n    return () => {\n      window.removeEventListener(\"dragenter\", enter);\n      window.removeEventListener(\"dragleave\", leave);\n      window.removeEventListener(\"dragover\", overWindow);\n      window.removeEventListener(\"drop\", reset);\n      window.removeEventListener(\"dragend\", reset);\n    };\n  }, [disabled]);\n\n  // Pasted screenshots arrive as \"image.png\"; give them a name someone can find later.\n  const fromClipboard = useCallback(\n    (data: DataTransfer | null) => {\n      const files = Array.from(data?.files ?? []);\n      if (!files.length) return false;\n      const stamp = new Date().toTimeString().slice(0, 8).replaceAll(\":\", \".\");\n      const named = files.map((f, i) =>\n        /^image\\.\\w+$/.test(f.name) ? new File([f], `Pasted image ${stamp}${files.length > 1 ? ` (${i + 1})` : \"\"}.${f.name.split(\".\")[1]}`, { type: f.type }) : f,\n      );\n      handle(named);\n      return true;\n    },\n    [handle],\n  );\n\n  useEffect(() => {\n    if (disabled || paste !== \"document\") return;\n    const onPaste = (e: ClipboardEvent) => {\n      const a = document.activeElement as HTMLElement | null;\n      const typing = a && !rootRef.current?.contains(a) && (a.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName));\n      if (typing || e.defaultPrevented) return;\n      if (fromClipboard(e.clipboardData)) e.preventDefault();\n    };\n    document.addEventListener(\"paste\", onPaste);\n    return () => document.removeEventListener(\"paste\", onPaste);\n  }, [disabled, paste, fromClipboard]);\n\n  const hasFiles = (e: React.DragEvent) => Array.from(e.dataTransfer.types).includes(\"Files\");\n\n  const lifted = phase === \"over\" || phase === \"armed\";\n  const fan = phase === \"over\" && preview.count > 1;\n  const glyph = phase === \"reject\" ? \"reject\" : phase === \"added\" ? \"added\" : \"upload\";\n\n  const headline =\n    phase === \"over\"\n      ? preview.count\n        ? `Release to add ${plural(preview.count, \"file\")}`\n        : \"Release to add files\"\n      : phase === \"reject\"\n        ? tooMany\n          ? \"One file at a time\"\n          : `${kinds} only`\n        : phase === \"armed\"\n          ? `Drop ${multiple ? \"files\" : \"a file\"} here`\n          : phase === \"added\"\n            ? `${plural(added, \"file\")} added`\n            : null;\n\n  const sizeNote = maxSize != null ? `up to ${formatBytes(maxSize)}${multiple ? \" each\" : \"\"}` : \"\";\n  const restHint = hint ?? ([kinds, sizeNote].filter(Boolean).join(\", \") || \"Any file type\");\n  const hintText = phase === \"reject\" ? (tooMany ? \"Drop them one at a time\" : preview.count > 1 ? \"These files can’t be added\" : \"This file type can’t be added\") : restHint;\n  const hintId = `${id}-hint`;\n\n  const swapIn = reduce ? { opacity: 0 } : { opacity: 0, y: 5, filter: \"blur(2px)\" };\n  const swapOut = reduce ? { opacity: 0 } : { opacity: 0, y: -5, filter: \"blur(2px)\" };\n  const md = size === \"md\";\n\n  return (\n    <div\n      ref={rootRef}\n      data-slot=\"dropzone\"\n      data-state={phase}\n      data-size={size}\n      data-invalid={invalid || undefined}\n      data-disabled={disabled || undefined}\n      onDragEnter={(e) => {\n        if (disabled || !hasFiles(e)) return;\n        e.preventDefault();\n        zoneDepth.current++;\n        if (zoneDepth.current > 1) return;\n        const items = Array.from(e.dataTransfer.items).filter((i) => i.kind === \"file\");\n        setPreview({ count: items.length, verdict: judgeDrag(items.map((i) => i.type), tokens) });\n        setRejection(\"\");\n        setOver(true);\n      }}\n      onDragOver={(e) => {\n        if (disabled || !hasFiles(e)) return;\n        e.preventDefault();\n        e.dataTransfer.dropEffect = \"copy\";\n      }}\n      onDragLeave={(e) => {\n        if (disabled || !hasFiles(e)) return;\n        zoneDepth.current = Math.max(0, zoneDepth.current - 1);\n        if (zoneDepth.current === 0) setOver(false);\n      }}\n      onDrop={(e) => {\n        if (disabled || !hasFiles(e)) return;\n        e.preventDefault();\n        zoneDepth.current = 0;\n        setOver(false);\n        setArmed(false);\n        handle(Array.from(e.dataTransfer.files));\n      }}\n      onPaste={(e) => {\n        if (disabled || paste !== \"focus\") return;\n        if (fromClipboard(e.clipboardData)) e.preventDefault();\n      }}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        // Escape clears a rejection message without choosing anything.\n        if (e.key === \"Escape\" && rejection) setRejection(\"\");\n      }}\n      className={cn(\n        \"group/dz relative isolate flex w-full min-w-0 rounded-xl text-fg\",\n        \"transition-[background-color] duration-200 ease-out\",\n        md ? \"min-h-44 flex-col items-center justify-center gap-3 px-6 py-7 text-center\" : \"min-h-16 items-center gap-3 py-3 pl-3 pr-3\",\n        \"hover:bg-hover/50 active:bg-hover\",\n        \"data-[state=armed]:bg-hover/50 data-[state=over]:bg-hover data-[state=reject]:bg-danger-soft\",\n        \"data-disabled:pointer-events-none data-disabled:opacity-50\",\n        className,\n      )}\n      {...rest}\n    >\n      {/* The border is drawn, not styled, so the dashes can stretch until they close into a solid line. */}\n      <svg aria-hidden className=\"pointer-events-none absolute inset-[0.5px] -z-10 size-[calc(100%-1px)] overflow-visible\">\n        <rect\n          width=\"100%\"\n          height=\"100%\"\n          rx=\"11.5\"\n          fill=\"none\"\n          strokeWidth=\"1\"\n          className={cn(\n            \"stroke-fg-4/70 [stroke-dasharray:5_4] transition-[stroke,stroke-dasharray] duration-300 ease-out-expo\",\n            \"group-hover/dz:stroke-fg-3/80\",\n            \"group-data-[state=armed]/dz:stroke-fg-3\",\n            \"group-data-[state=over]/dz:stroke-fg-2 group-data-[state=over]/dz:[stroke-dasharray:9_0]\",\n            \"group-data-[state=reject]/dz:stroke-danger/70\",\n            \"group-data-invalid/dz:stroke-danger/60\",\n          )}\n        />\n      </svg>\n\n      {/* The whole zone is one button: click, Enter or Space opens the file picker. */}\n      <button\n        type=\"button\"\n        disabled={disabled}\n        aria-describedby={hintId}\n        onClick={() => inputRef.current?.click()}\n        className=\"absolute inset-0 z-10 cursor-pointer rounded-xl outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\"\n      >\n        <span className=\"sr-only\">{label}</span>\n      </button>\n      <input\n        ref={inputRef}\n        type=\"file\"\n        tabIndex={-1}\n        aria-hidden\n        className=\"hidden\"\n        accept={tokens.join(\",\") || undefined}\n        multiple={multiple}\n        disabled={disabled}\n        onChange={(e) => {\n          handle(Array.from(e.currentTarget.files ?? []));\n          // Picking the same file twice in a row should still count.\n          e.currentTarget.value = \"\";\n        }}\n      />\n\n      <Tile glyph={glyph} lifted={lifted} fan={fan} reduce={!!reduce} size={size} />\n\n      <div className={cn(\"pointer-events-none flex min-w-0 flex-col\", md ? \"items-center gap-1\" : \"flex-1 gap-0.5\")}>\n        <div className={cn(\"grid w-full\", md && \"justify-items-center\")}>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            <motion.p\n              key={headline ?? \"rest\"}\n              initial={swapIn}\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={{ ...swapOut, transition: { duration: 0.12, ease: ease.in } }}\n              transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out }}\n              className={cn(\n                \"col-start-1 row-start-1 max-w-full truncate font-medium tracking-[-0.01em]\",\n                md ? \"text-[13.5px]\" : \"text-[13px]\",\n                phase === \"reject\" && \"text-danger\",\n              )}\n            >\n              {headline ??\n                title ?? (\n                  <>\n                    <span className=\"pointer-coarse:hidden\">\n                      Drop {multiple ? \"files\" : \"a file\"} here or{\" \"}\n                      <span className=\"underline decoration-fg-4 underline-offset-[3px] transition-[text-decoration-color] duration-150 group-hover/dz:decoration-fg-2\">\n                        browse\n                      </span>\n                    </span>\n                    <span className=\"hidden pointer-coarse:inline\">Choose {multiple ? \"files\" : \"a file\"}</span>\n                  </>\n                )}\n            </motion.p>\n          </AnimatePresence>\n        </div>\n\n        <motion.div layout={reduce ? false : \"position\"} transition={spring.soft} id={hintId} className={cn(\"min-w-0 text-[12px] leading-[18px]\", md && \"max-w-[34ch]\")}>\n          <AnimatePresence initial={false} mode=\"popLayout\">\n            {invalid ? (\n              <motion.p\n                key={`e-${typeof message === \"string\" ? message : \"custom\"}`}\n                initial={swapIn}\n                animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                transition={{ duration: reduce ? 0.15 : 0.22, ease: ease.out }}\n                className={cn(\"text-danger\", md ? \"text-balance\" : \"flex items-start gap-1.5\")}\n              >\n                <AlertGlyph inline={md} />\n                <span className=\"min-w-0\">{md ? <Sentences text={message} /> : message}</span>\n              </motion.p>\n            ) : (\n              <motion.p\n                key={phase === \"reject\" ? \"reject-hint\" : \"hint\"}\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                transition={{ duration: 0.18 }}\n                className={cn(\"text-fg-3\", md ? \"text-balance\" : \"truncate\")}\n              >\n                {hintText}\n                {paste && md && phase !== \"reject\" && (\n                  <span className=\"pointer-coarse:hidden\">\n                    <span className=\"px-1.5 text-fg-4\">·</span>\n                    <kbd className=\"font-sans text-fg-3\">\n                      <PasteKeys /> to paste\n                    </kbd>\n                  </span>\n                )}\n              </motion.p>\n            )}\n          </AnimatePresence>\n        </motion.div>\n      </div>\n\n      {!md && (\n        <span\n          aria-hidden\n          className={cn(\n            \"pointer-events-none inline-flex h-7 shrink-0 items-center rounded-md border border-line-2 bg-raised px-2.5 text-[12px] font-medium text-fg shadow-[var(--shadow)]\",\n            \"transition-[background-color,border-color,scale] duration-150 ease-out group-hover/dz:border-fg-4 group-active/dz:scale-[0.97]\",\n          )}\n        >\n          {browseLabel}\n        </span>\n      )}\n\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announce.text}\n        {announce.n % 2 ? \" \" : \"\"}\n      </span>\n    </div>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* Pieces                                                              */\n/* ------------------------------------------------------------------ */\n\n// \"x.jpg is 24 MB. The limit is 10 MB.\" reads best as two lines in the tall zone: the problem, then the rule.\nfunction Sentences({ text }: { text: React.ReactNode }) {\n  if (typeof text !== \"string\") return <>{text}</>;\n  const at = text.search(/\\. [A-Z]/);\n  if (at < 0) return <>{text}</>;\n  return (\n    <>\n      {text.slice(0, at + 1)} <span className=\"sm:block\">{text.slice(at + 2)}</span>\n    </>\n  );\n}\n\nconst svg = { width: 16, height: 16, viewBox: \"0 0 16 16\", fill: \"none\", stroke: \"currentColor\", strokeWidth: 1.4, strokeLinecap: \"round\" as const, strokeLinejoin: \"round\" as const, \"aria-hidden\": true };\n\nfunction AlertGlyph({ inline }: { inline?: boolean }) {\n  return (\n    <svg {...svg} width={14} height={14} className={inline ? \"mr-1.5 inline-block align-[-2.5px]\" : \"mt-0.5 shrink-0\"}>\n      <circle cx=\"8\" cy=\"8\" r=\"5.75\" />\n      <path d=\"M8 5v3.5\" />\n      <circle cx=\"8\" cy=\"10.9\" r=\".6\" fill=\"currentColor\" stroke=\"none\" />\n    </svg>\n  );\n}\n\n// ⌘V on Apple keyboards, Ctrl+V elsewhere. Rendered as text after mount so the server and client agree.\nconst noop = () => () => {};\nfunction PasteKeys() {\n  const mac = useSyncExternalStore(noop, () => /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent), () => true);\n  return <>{mac ? \"⌘V\" : \"Ctrl+V\"}</>;\n}\n\n/**\n * The icon tile. It lifts while files hover the window, fans a stack of pages\n * behind it when several files are over the zone, and swaps its glyph for a\n * drawn tick on success or a stop sign on a wrong type.\n */\nfunction Tile({ glyph, lifted, fan, reduce, size }: { glyph: \"upload\" | \"reject\" | \"added\"; lifted: boolean; fan: boolean; reduce: boolean; size: \"sm\" | \"md\" }) {\n  const md = size === \"md\";\n  const box = md ? \"size-10 rounded-[10px]\" : \"size-9 rounded-lg\";\n  const page = cn(\"absolute inset-0 border border-line-2 bg-raised\", box);\n  return (\n    <div aria-hidden className={cn(\"pointer-events-none relative shrink-0\", box)}>\n      {[-1, 1].map((side) => (\n        <motion.span\n          key={side}\n          className={page}\n          initial={false}\n          animate={fan && !reduce ? { opacity: 1, rotate: side * 9, x: side * 7, y: 1 } : { opacity: 0, rotate: 0, x: 0, y: 0 }}\n          transition={spring.snappy}\n        />\n      ))}\n      <motion.span\n        className={cn(\n          page,\n          \"grid place-items-center text-fg-2 shadow-[var(--shadow)] transition-[color,border-color] duration-200\",\n          \"group-hover/dz:text-fg group-data-[state=over]/dz:text-fg group-data-[state=armed]/dz:text-fg\",\n          \"group-data-[state=reject]/dz:border-danger/40 group-data-[state=reject]/dz:text-danger group-data-[state=added]/dz:text-fg\",\n          \"group-active/dz:scale-[0.96]\",\n        )}\n        initial={false}\n        // Same keys either way so the server render and a reduced-motion client agree.\n        animate={{ y: lifted && !reduce ? -4 : 0, scale: fan && !reduce ? 1.04 : 1 }}\n        transition={spring.snappy}\n      >\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          <motion.span\n            key={glyph}\n            className=\"grid place-items-center\"\n            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n            animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: \"blur(3px)\" }}\n            transition={reduce ? { duration: 0.15 } : spring.pop}\n          >\n            {glyph === \"added\" ? (\n              <svg {...svg}>\n                <motion.path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.32, ease: ease.out, delay: 0.05 }} />\n              </svg>\n            ) : glyph === \"reject\" ? (\n              <svg {...svg}>\n                <circle cx=\"8\" cy=\"8\" r=\"5.25\" />\n                <path d=\"m4.4 11.6 7.2-7.2\" />\n              </svg>\n            ) : (\n              <svg {...svg}>\n                {/* The arrow rides up a little further than the tile, so the lift reads as \"send it up\". */}\n                <path d=\"M3 10.5v1.75c0 .55.45 1 1 1h8c.55 0 1-.45 1-1V10.5\" />\n                <g\n                  className={cn(\n                    \"transition-transform duration-300 ease-out-expo motion-reduce:transition-none\",\n                    \"group-hover/dz:-translate-y-px\",\n                    lifted && \"-translate-y-[1.5px] group-hover/dz:-translate-y-[1.5px]\",\n                  )}\n                >\n                  <path d=\"M8 9.75V2.75M5 5.5 8 2.5l3 3\" />\n                </g>\n              </svg>\n            )}\n          </motion.span>\n        </AnimatePresence>\n      </motion.span>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/dropzone"
}
