{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "sticker-drop", "title": "Sticker Drop", "description": "File dropzone built on the backing paper: the zone is the kiss-cut sheet, dragging lights the cut lines, and each accepted file lands as its own sticker. Wraps a clipped — not hidden — file input, so it stays keyboard operable. Controllable through files, so a submit can clear the sheet.", "dependencies": [ "lucide-react" ], "registryDependencies": [ "@duck/theme" ], "files": [ { "path": "registry/duck/ui/sticker-drop.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Upload, X } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * StickerDrop — the backing paper itself. The zone is the kiss-cut sheet,\n * dragging over it lights the cut lines, and every accepted file lands on the\n * sheet as its own sticker.\n *\n * A drop zone is not keyboard operable, so this one wraps a real file input\n * that is clipped rather than hidden — it still takes focus, still opens the\n * picker on Enter, and still satisfies WCAG 2.5.7 by giving dragging a\n * single-pointer alternative.\n *\n * It keeps its own list until you pass `files`, at which point yours is the only\n * one that counts. That seam exists for the moment after a successful submit:\n * the form has let go of the file and the sheet is still showing it, and without\n * a controlled list the only way to clear it is to remount the component with a\n * changed `key` — which works, and which no reviewer should have to accept.\n */\n\nfunction formatSize(bytes: number) {\n if (bytes < 1024) return `${bytes} B`;\n if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;\n return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;\n}\n\nfunction matchesAccept(file: File, accept?: string) {\n if (!accept) return true;\n return accept.split(\",\").some((raw) => {\n const rule = raw.trim().toLowerCase();\n if (!rule) return false;\n if (rule.startsWith(\".\")) return file.name.toLowerCase().endsWith(rule);\n if (rule.endsWith(\"/*\")) return file.type.startsWith(rule.slice(0, -1));\n return file.type.toLowerCase() === rule;\n });\n}\n\nexport interface StickerDropProps\n extends Omit, \"onChange\"> {\n accept?: string;\n multiple?: boolean;\n /** Largest file allowed, in bytes. */\n maxSize?: number;\n /**\n * The list, held by you. Pass it and the zone stops keeping its own: it draws\n * what you give it, `onFilesChange` becomes intent rather than a notification,\n * and passing `[]` empties the sheet.\n */\n files?: File[];\n /** Called with the full list every time it changes. */\n onFilesChange?: (files: File[]) => void;\n label?: string;\n hint?: string;\n}\n\nfunction StickerDrop({\n className,\n accept,\n multiple = false,\n maxSize,\n files,\n onFilesChange,\n label = \"Drop files here\",\n hint,\n ...props\n}: StickerDropProps) {\n const controlled = files !== undefined;\n const [internal, setInternal] = React.useState([]);\n const current = controlled ? files : internal;\n const [dragging, setDragging] = React.useState(false);\n const [announcement, setAnnouncement] = React.useState(\"\");\n // dragenter and dragleave fire for every child the pointer crosses. Counting\n // them is the only way to know when the pointer has really left the zone.\n const depth = React.useRef(0);\n const inputRef = React.useRef(null);\n const listRef = React.useRef(null);\n\n const commit = React.useCallback(\n (incoming: FileList | null) => {\n if (!incoming?.length) return;\n const accepted: File[] = [];\n const rejected: string[] = [];\n\n for (const file of Array.from(incoming)) {\n if (!matchesAccept(file, accept)) {\n rejected.push(`${file.name} — type not allowed`);\n } else if (maxSize && file.size > maxSize) {\n rejected.push(`${file.name} — over ${formatSize(maxSize)}`);\n } else {\n accepted.push(file);\n }\n }\n\n const next = multiple ? [...current, ...accepted] : accepted.slice(0, 1);\n if (!controlled) setInternal(next);\n onFilesChange?.(next);\n\n // Announced either way. Controlled or not, the files were read.\n setAnnouncement(\n [\n accepted.length &&\n `${accepted.length} file${accepted.length === 1 ? \"\" : \"s\"} added`,\n rejected.length && `Rejected: ${rejected.join(\", \")}`,\n ]\n .filter(Boolean)\n .join(\". \")\n );\n },\n [accept, controlled, current, maxSize, multiple, onFilesChange]\n );\n\n const remove = React.useCallback(\n (index: number) => {\n const removed = current[index];\n const next = current.filter((_, i) => i !== index);\n if (!controlled) setInternal(next);\n onFilesChange?.(next);\n setAnnouncement(`${removed.name} removed`);\n // Focus would otherwise fall to and the keyboard user would\n // restart from the top of the page. Runs after the parent's render in\n // controlled mode, so the list it queries is the new one either way.\n requestAnimationFrame(() => {\n const buttons =\n listRef.current?.querySelectorAll(\"button\");\n (buttons?.[Math.min(index, (buttons?.length ?? 1) - 1)] ??\n inputRef.current)?.focus();\n });\n },\n [controlled, current, onFilesChange]\n );\n\n return (\n \n {\n event.preventDefault();\n depth.current += 1;\n setDragging(true);\n }}\n onDragOver={(event) => {\n // Without this the browser navigates away to the dropped file.\n event.preventDefault();\n }}\n onDragLeave={() => {\n depth.current -= 1;\n if (depth.current <= 0) {\n depth.current = 0;\n setDragging(false);\n }\n }}\n onDrop={(event) => {\n event.preventDefault();\n depth.current = 0;\n setDragging(false);\n commit(event.dataTransfer.files);\n }}\n className={cn(\n \"kiss-cut relative flex cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl px-6 py-10 text-center\",\n // Dashed cut lines at sticker weight, so the drag state can switch\n // them to solid without shifting anything.\n \"sticker border-dashed border-cut\",\n \"transition-[border-color,box-shadow] duration-200 ease-[var(--ease-duck)]\",\n \"hover:border-primary/60\",\n // --cut on --sheet is about 1.8:1. Fine as decoration in a sheet,\n // not fine as the drag-active status indicator, so lime carries it.\n \"data-[dragging]:border-solid data-[dragging]:border-primary data-[dragging]:duck-glow-primary\",\n \"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 has-[:focus-visible]:ring-offset-background\",\n \"has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50\"\n )}\n >\n