{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "paste-area",
  "title": "Paste Area",
  "description": "A region that takes whatever is on the clipboard — a screenshot, an image, a file, a table copied out of a spreadsheet, or plain text — and hands it over as typed items, with each pasted picture given a name it can actually be filed under instead of the image.png that every browser calls it. Reach for it wherever the thing a person has is copied rather than saved: a screenshot going into a bug report, a support ticket or a QA tool, an image dropped into a chat message, a CMS entry or a comment box, an avatar or profile picture grabbed from somewhere else, a receipt or an invoice pasted into an expense claim, rows pasted out of Excel, Numbers or Google Sheets into an importer, a logo pasted into a brand or settings form, and a frame copied straight out of Figma during a design handoff. Common asks it answers: \"paste image react\", \"paste screenshot upload react\", \"react paste from clipboard component\", \"onPaste handler react\", \"get image from clipboard javascript\", \"clipboardData.items getAsFile\", \"clipboardData.files is empty\", \"paste event has no files\", \"screenshot paste not working\", \"paste image works in chrome not safari\", \"navigator.clipboard.read example\", \"clipboard read permission denied\", \"NotAllowedError clipboard\", \"paste area shadcn\", \"shadcn paste component\", \"paste to upload\", \"ctrl+v upload\", \"cmd+v upload image\", \"paste excel table into react\", \"paste tsv into a table\", \"text/html vs text/plain clipboard\", \"pasted image has no filename\", \"every pasted file is called image.png\", \"paste anywhere on the page\", \"document paste listener react\", \"paste event only fires when focused\", \"paste event not firing on div\", \"クリップボード 貼り付け react\", \"スクショ 貼り付け アップロード\", \"画像 ペースト 受け取る\". Official shadcn/ui has nothing here and nothing to build it out of: fetching every entry in its registry today — 63 listed, 62 fetchable, questionnaire is indexed and 404s on both style tracks — and grepping all 223,287 bytes of source, onPaste, clipboardData, ClipboardEvent, DataTransfer, navigator.clipboard, getAsFile, ClipboardItem, clipboard.read and readText are every one of them a zero hit (measured 2026-09-29), and so is the word paste itself, in either case. It does not touch the clipboard anywhere. Distinct from the pulld pieces it sits beside, which all face the other way: copy-button and copy-field write to the clipboard, file-dropzone receives a file that already exists on disk by drag or by picker, camera-capture makes a new picture, image-crop and image-zoom work on a picture you already hold, and upload-list shows them afterwards. This is the only one that reads. It is deliberately stateless too — it hands each paste on and keeps no list, so it composes with whichever of those holds the files. Seven things separate it from the version built out of onPaste and e.clipboardData.files. First, the two clipboard mechanisms are available under opposite conditions, so both are wired: the paste event needs no permission and works everywhere but only ever reaches the focused element — which is why the region is focusable and why a global prop exists for a screen whose whole job is to receive one thing — while navigator.clipboard.read() can be called at any moment but prompts for permission, is not implemented everywhere, and in some engines only resolves inside a user gesture, which is why it sits behind a button. Second, that button appears only after mount: the server has no navigator, so deciding it during render puts a button in the client's first tree that is missing from the server's HTML, and React throws the whole tree away rather than reconciling that. Third, files and items disagree about the most common paste there is. A screenshot is a picture that was never a file on disk, so it turns up in items and, in some browsers, nowhere else — read files alone and the single thing people paste most is silently ignored. Both are read and then de-duplicated, because an ordinary copied file is usually in both; and the identity used for that is name, size and type but deliberately not lastModified, since getAsFile() mints a fresh File on every call and some engines stamp it with the current millisecond, so the two reads of one picture differ by a millisecond and the person is handed their screenshot twice. Fourth, one paste carries several flavours at once and choosing between them is not this component's call: a spreadsheet selection arrives as a real table in text/html and as tab-separated text in text/plain, and which one is read changes the result entirely, so both are handed over along with the full list of types. The strings are read with getData, synchronously, rather than with items[i].getAsString, whose callback runs on a later turn — by which time the event's DataTransfer has been emptied and it fires with nothing. Fifth, every pasted screenshot is called image.png. One is fine; the second lands in the list beside a file of the same name, and from there \"which image.png?\" is a question the UI cannot answer and an upload keyed on filename quietly overwrites. Placeholders are renamed with a timestamp and a sequence that continues across pastes rather than restarting, so two pastes inside the same second cannot collide either, and the original name is reported back. A file that came with a real name keeps it — photo.jpg is deliberately not on the list of placeholders, because renaming it would destroy the only information its owner supplied. Sixth, a paste that belongs to a text box is left to it. A region holding a caption field, or one listening on the document, is handed pastes meant for an input, and swallowing one means someone watches their text simply not appear; a read-only or disabled field is not one of those, since nothing can be pasted into it, so the region takes that one. Seventh, in global mode the element's own handler is dropped rather than added to, because a paste event bubbles to the document as well — keeping both delivers one Ctrl+V twice, as two copies of the same screenshot. Files are screened with accept, maxSize and maxFiles and anything skipped is reported through onReject instead of vanishing; every stage is announced through a live region that is in the DOM before it has anything to say, and visibly rather than only to screen readers, because \"clipboard permission denied\" is the answer to \"why did nothing happen\" and sighted people ask that too. One file, shadcn tokens throughout so it follows light and dark themes, and lucide-react is the only dependency.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/paste-area.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ClipboardPaste, Loader2 } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/** The clipboard's name for unformatted text. */\nexport const TEXT_TYPE = \"text/plain\"\n\n/** The clipboard's name for the rich-text flavour that rides along with it. */\nexport const HTML_TYPE = \"text/html\"\n\n/**\n * Extensions for the types a clipboard actually carries. Everything else is derived from the\n * subtype, so an unlisted `image/avif` still lands on `.avif` rather than on a guess.\n */\nconst EXTENSION_BY_TYPE: Record<string, string> = {\n  \"image/jpeg\": \"jpg\",\n  \"image/svg+xml\": \"svg\",\n  \"image/x-icon\": \"ico\",\n  \"image/vnd.microsoft.icon\": \"ico\",\n  \"text/plain\": \"txt\",\n  \"text/html\": \"html\",\n  \"text/csv\": \"csv\",\n  \"application/json\": \"json\",\n  \"application/pdf\": \"pdf\",\n  \"application/zip\": \"zip\",\n  \"application/msword\": \"doc\",\n}\n\n/**\n * A file extension for a MIME type, without the dot.\n *\n * Parameters are dropped first (`image/png; charset=binary` is still a png), then the subtype is\n * stripped of the `x-` prefix and of any `+xml`-style suffix. Anything that does not come out as a\n * plausible extension becomes `bin`, because a name ending in `.application/octet-stream` is worse\n * than one ending in nothing useful.\n */\nexport function extensionForType(type: string | null | undefined): string {\n  const clean = String(type ?? \"\")\n    .split(\";\")[0]\n    .trim()\n    .toLowerCase()\n  if (EXTENSION_BY_TYPE[clean]) return EXTENSION_BY_TYPE[clean]\n  const subtype = clean.split(\"/\")[1]\n  if (!subtype) return \"bin\"\n  const base = subtype.replace(/^x-/, \"\").replace(/\\+.*$/, \"\")\n  return /^[a-z0-9]{1,8}$/.test(base) ? base : \"bin\"\n}\n\n/**\n * The stems browsers invent when the clipboard hands over a picture that was never a file.\n *\n * Deliberately short. `photo` and `screenshot` are **not** here: a real file called `photo.jpg`,\n * copied out of a file manager, has a name its owner chose, and renaming it would be this component\n * destroying information rather than supplying it. Everything in this list is a name no human typed.\n */\nconst GENERIC_STEMS = new Set([\"\", \"image\", \"unknown\", \"blob\"])\n\n/** Whether a clipboard-supplied filename is a placeholder rather than something a person chose. */\nexport function isGenericName(name: string | null | undefined): boolean {\n  if (!name) return true\n  const dot = name.lastIndexOf(\".\")\n  const stem = (dot === -1 ? name : name.slice(0, dot)).trim().toLowerCase()\n  return GENERIC_STEMS.has(stem)\n}\n\n/**\n * The `20260929-143012` part of a generated filename, in local time.\n *\n * Local rather than UTC on purpose: this ends up in a filename a person reads back, and a screenshot\n * taken at half past two in the afternoon should not be called `0530`.\n */\nexport function stampFor(date: Date): string {\n  const pad = (n: number) => String(n).padStart(2, \"0\")\n  return (\n    `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +\n    `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`\n  )\n}\n\n/** The name given to a pasted image that arrived without one. */\nexport function pastedName(type: string | null | undefined, stamp: string, sequence: number): string {\n  return `pasted-${stamp}-${sequence}.${extensionForType(type)}`\n}\n\nfunction extOf(name: string) {\n  const dot = name.lastIndexOf(\".\")\n  return dot === -1 ? \"\" : name.slice(dot).toLowerCase()\n}\n\n/** Match a file against an `accept` string (mime, mime wildcard, or .ext) — `<input accept>` syntax. */\nexport function matchesAccept(file: File, accept?: string): boolean {\n  if (!accept) return true\n  const type = (file.type || \"\").toLowerCase()\n  const ext = extOf(file.name || \"\")\n  return accept\n    .split(\",\")\n    .map((token) => token.trim().toLowerCase())\n    .filter(Boolean)\n    .some((token) => {\n      if (token.startsWith(\".\")) return ext === token\n      if (token.endsWith(\"/*\")) return type.startsWith(token.slice(0, -1))\n      return type === token\n    })\n}\n\n/** Why a pasted file was skipped, so you can tell the person instead of dropping it silently. */\nexport interface PasteRejection {\n  file: File\n  reason: \"type\" | \"size\" | \"too-many\"\n}\n\n/** The caps a paste is measured against. They apply to files; pasted text is never capped. */\nexport interface PasteLimits {\n  accept?: string\n  maxSize?: number\n  maxFiles?: number\n}\n\n/** Split the files a paste carried into the ones to hand on and the ones to explain. */\nexport function screenFiles(\n  files: File[],\n  { accept, maxSize, maxFiles }: PasteLimits\n): { accepted: File[]; rejected: PasteRejection[] } {\n  const accepted: File[] = []\n  const rejected: PasteRejection[] = []\n  for (const file of files) {\n    if (!matchesAccept(file, accept)) rejected.push({ file, reason: \"type\" })\n    else if (maxSize !== undefined && file.size > maxSize) rejected.push({ file, reason: \"size\" })\n    else accepted.push(file)\n  }\n  if (maxFiles !== undefined && accepted.length > maxFiles) {\n    for (const file of accepted.slice(maxFiles)) rejected.push({ file, reason: \"too-many\" })\n    return { accepted: accepted.slice(0, maxFiles), rejected }\n  }\n  return { accepted, rejected }\n}\n\n/**\n * Identity for de-duplication: name, size and type — and deliberately **not** `lastModified`.\n *\n * The same screenshot is normally present in both `items` and `files`, but `getAsFile()` mints a\n * fresh `File` on each call and some engines stamp it with `Date.now()` at that moment, so two reads\n * of one picture differ by a millisecond or two. Keying on the timestamp would let that pair through\n * as two files and the person would see their screenshot twice.\n */\nconst identityOf = (file: File) => `${file.name}\\u0000${file.size}\\u0000${file.type}`\n\n/** Merge the file lists a single paste exposes, keeping the first sighting of each file. */\nexport function dedupeFiles(...groups: (File[] | null | undefined)[]): File[] {\n  const out: File[] = []\n  const seen = new Set<string>()\n  for (const group of groups) {\n    for (const file of group ?? []) {\n      if (!file) continue\n      const key = identityOf(file)\n      if (seen.has(key)) continue\n      seen.add(key)\n      out.push(file)\n    }\n  }\n  return out\n}\n\n/** The parts of a `DataTransfer` this reads, narrowed so a stand-in can be handed to it. */\nexport interface ClipboardSource {\n  types?: ArrayLike<string> | readonly string[]\n  files?: ArrayLike<File>\n  items?: ArrayLike<{ kind: string; type: string; getAsFile(): File | null }>\n  getData?(type: string): string\n}\n\n/** Everything one paste carried, before any of it has been screened or renamed. */\nexport interface RawPaste {\n  files: File[]\n  text: string | null\n  html: string | null\n  /** Every flavour the clipboard advertised, verbatim — including the `Files` pseudo-type. */\n  types: string[]\n}\n\nconst EMPTY_PASTE: RawPaste = { files: [], text: null, html: null, types: [] }\n\n/**\n * `getData` is synchronous and safe to call during the event; it throws in some engines once the\n * transfer has been neutered, which is a reason to shrug rather than to lose the whole paste.\n */\nfunction readString(source: ClipboardSource, type: string): string | null {\n  try {\n    return source.getData?.(type) || null\n  } catch {\n    return null\n  }\n}\n\n/**\n * Read one `paste` event's clipboard, synchronously.\n *\n * Both file lists are read, because they disagree: a screenshot — a picture that was never a file on\n * disk — shows up in `items` and, in some browsers, nowhere else, so code that reads `files` alone\n * silently ignores the single most common thing anyone pastes. They are then de-duplicated, since\n * most browsers put an ordinary copied file in both.\n *\n * The strings come from `getData` rather than from `items[i].getAsString`, which hands its result to\n * a callback on a later turn — by which time the event's `DataTransfer` is empty and the callback\n * fires with nothing. Everything here happens before this function returns, for the same reason.\n */\nexport function readClipboard(source: ClipboardSource | null | undefined): RawPaste {\n  if (!source) return EMPTY_PASTE\n  const fromItems: File[] = []\n  for (const item of Array.from(source.items ?? [])) {\n    if (item?.kind !== \"file\") continue\n    const file = item.getAsFile?.()\n    if (file) fromItems.push(file)\n  }\n  return {\n    files: dedupeFiles(fromItems, Array.from(source.files ?? [])),\n    text: readString(source, TEXT_TYPE),\n    html: readString(source, HTML_TYPE),\n    types: Array.from(source.types ?? []),\n  }\n}\n\n/** One entry of `navigator.clipboard.read()`, narrowed the same way as `ClipboardSource`. */\nexport interface ClipboardItemLike {\n  types: readonly string[]\n  getType(type: string): Promise<Blob>\n}\n\n/**\n * Read the result of `navigator.clipboard.read()` into the same shape a `paste` event produces.\n *\n * The blobs come out unnamed on purpose: a `ClipboardItem` has no filename to offer at all, so\n * handing them on with an empty name lets the one naming pass in `normalizeFiles` treat them exactly\n * like a browser's `image.png` instead of there being a second, subtly different naming path.\n *\n * A type that fails to read is skipped rather than aborting: a clipboard holding one unreadable\n * flavour alongside a picture should still yield the picture.\n */\nexport async function readClipboardItems(\n  items: readonly ClipboardItemLike[] | null | undefined\n): Promise<RawPaste> {\n  const files: File[] = []\n  const types: string[] = []\n  let text: string | null = null\n  let html: string | null = null\n\n  for (const item of items ?? []) {\n    for (const type of item?.types ?? []) {\n      types.push(type)\n      try {\n        const blob = await item.getType(type)\n        if (type === TEXT_TYPE) {\n          if (text === null) text = (await blob.text()) || null\n        } else if (type === HTML_TYPE) {\n          if (html === null) html = (await blob.text()) || null\n        } else {\n          files.push(new File([blob], \"\", { type: blob.type || type }))\n        }\n      } catch {\n        // This flavour is unreadable. The rest of the clipboard still is.\n      }\n    }\n  }\n  return { files, text, html, types }\n}\n\n/** A file from a paste, together with what the clipboard called it before this renamed it. */\nexport interface PastedFile {\n  file: File\n  /** True when the clipboard offered no usable name and this supplied one. */\n  renamed: boolean\n  /** Whatever the clipboard called it — `image.png`, or null when it gave no name at all. */\n  originalName: string | null\n}\n\n/**\n * Give pasted pictures names that can coexist.\n *\n * Every screenshot arrives as `image.png`. One is fine; the second one lands in the list beside a\n * file of the same name, and from there on \"which `image.png`?\" is a question the UI cannot answer\n * and an upload that keys on filename quietly overwrites. `sequence` continues across pastes rather\n * than restarting, so two pastes inside the same second cannot produce the same name either.\n *\n * Files that came with a real name keep it, untouched.\n */\nexport function normalizeFiles(\n  files: File[],\n  { stamp, sequence = 1, rename = true }: { stamp: string; sequence?: number; rename?: boolean }\n): PastedFile[] {\n  let next = sequence\n  return files.map((file) => {\n    const originalName = file.name || null\n    if (!rename || !isGenericName(file.name)) return { file, renamed: false, originalName }\n    const named = new File([file], pastedName(file.type, stamp, next++), {\n      type: file.type,\n      lastModified: file.lastModified,\n    })\n    return { file: named, renamed: true, originalName }\n  })\n}\n\n/** Where a paste came from: the keyboard, or the button that asks the clipboard directly. */\nexport type PasteSource = \"event\" | \"clipboard-api\"\n\n/** One paste, screened and named, with every flavour it carried still available. */\nexport interface PastePayload {\n  files: PastedFile[]\n  /** The `text/plain` flavour. A spreadsheet puts tab-separated rows here. */\n  text: string | null\n  /** The `text/html` flavour. A spreadsheet puts a real `<table>` here. */\n  html: string | null\n  types: string[]\n  source: PasteSource\n}\n\n/**\n * The sentence announced after a paste. Empty when the paste carried nothing at all, which the\n * component reports with its own label instead.\n */\nexport function describePaste(\n  payload: Pick<PastePayload, \"files\" | \"text\" | \"html\">,\n  rejected: PasteRejection[]\n): string {\n  const parts: string[] = []\n  const count = payload.files.length\n  if (count) parts.push(`${count} file${count === 1 ? \"\" : \"s\"} pasted`)\n  else if (payload.text) parts.push(\"Text pasted\")\n  else if (payload.html) parts.push(\"Formatted text pasted\")\n  if (rejected.length) parts.push(`${rejected.length} skipped`)\n  return parts.join(\", \")\n}\n\nconst NON_TEXT_INPUTS = new Set([\n  \"button\",\n  \"checkbox\",\n  \"color\",\n  \"file\",\n  \"hidden\",\n  \"image\",\n  \"radio\",\n  \"range\",\n  \"reset\",\n  \"submit\",\n])\n\n/** The shape of an event target this needs to classify, without requiring a real DOM. */\ninterface TargetLike {\n  tagName?: string\n  type?: string\n  isContentEditable?: boolean\n  readOnly?: boolean\n  disabled?: boolean\n  tabIndex?: number\n}\n\n/**\n * Whether a paste that landed on this element belongs to the element rather than to the region.\n *\n * A paste area that also holds a caption field, or that listens on the document, will be handed\n * pastes meant for a text box — and swallowing one means the person watches their text not appear.\n * A read-only or disabled field is not one of those: nothing can be pasted into it, so the region\n * takes that paste.\n */\nexport function isEditableTarget(target: unknown): boolean {\n  if (!target || typeof target !== \"object\") return false\n  const el = target as TargetLike\n  if (el.isContentEditable) return true\n  const tag = String(el.tagName ?? \"\").toUpperCase()\n  if (tag === \"TEXTAREA\") return !el.readOnly && !el.disabled\n  if (tag === \"INPUT\") {\n    if (NON_TEXT_INPUTS.has(String(el.type ?? \"text\").toLowerCase())) return false\n    return !el.readOnly && !el.disabled\n  }\n  return false\n}\n\nconst FOCUSABLE_TAGS = new Set([\"A\", \"BUTTON\", \"INPUT\", \"SELECT\", \"TEXTAREA\"])\n\n/**\n * Whether a click landed on something that takes focus in its own right.\n *\n * Clicking the region focuses it, so that the very next Ctrl+V has somewhere to go. Doing that\n * unconditionally would also fire when the click was on the Paste button inside it, moving focus off\n * the control the person just used.\n */\nexport function isFocusableTarget(target: unknown): boolean {\n  if (!target || typeof target !== \"object\") return false\n  const el = target as TargetLike\n  if (FOCUSABLE_TAGS.has(String(el.tagName ?? \"\").toUpperCase())) return true\n  return typeof el.tabIndex === \"number\" && el.tabIndex >= 0\n}\n\n/** A refusal from `navigator.clipboard.read()` that is about permission rather than about failure. */\nexport function isPermissionError(error: unknown): boolean {\n  const name = (error as { name?: string } | null)?.name\n  return name === \"NotAllowedError\" || name === \"SecurityError\"\n}\n\n/** Every string a person reads or hears. Override any of them to reword or to translate. */\nexport interface PasteAreaLabels {\n  /** The region's accessible name. */\n  label: string\n  /** The instruction shown inside the region. */\n  hint: string\n  /** The button that reads the clipboard without a keystroke. */\n  button: string\n  /** While that read is in flight. */\n  reading: string\n  /** The paste carried nothing this component could use. */\n  empty: string\n  /** The browser refused to hand over the clipboard. */\n  denied: string\n  /** Reading the clipboard failed for some other reason. */\n  failed: string\n}\n\nconst defaultLabels: PasteAreaLabels = {\n  label: \"Paste area\",\n  hint: \"Paste a screenshot, an image or a file here\",\n  button: \"Paste from clipboard\",\n  reading: \"Reading the clipboard…\",\n  empty: \"Nothing to paste\",\n  denied: \"Clipboard permission denied — use Ctrl+V instead\",\n  failed: \"Could not read the clipboard\",\n}\n\nexport interface PasteAreaProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"onPaste\"> {\n  /** Called once per paste that carried something usable. */\n  onPaste: (payload: PastePayload) => void\n  /** Called with the files that were skipped and why. */\n  onReject?: (rejections: PasteRejection[]) => void\n  /** Same syntax as an `<input accept>` attr, e.g. `\"image/*,.pdf\"`. Files only. */\n  accept?: string\n  /** Skip files larger than this many bytes. */\n  maxSize?: number\n  /** Keep at most this many files out of a single paste. */\n  maxFiles?: number\n  /**\n   * Also take pastes that happen while nothing in this region is focused.\n   *\n   * The usual reason a paste area \"does nothing\" is that a `paste` event only reaches whatever has\n   * focus, and a page someone has just loaded has focus on the body. Turn this on for a screen whose\n   * whole job is to receive one thing, leave it off when the page has other paste targets.\n   */\n  global?: boolean\n  /** Keep the clipboard-reading button out of the tree, e.g. when supplying your own control. */\n  hideButton?: boolean\n  /** Keep whatever name the clipboard supplied, even when it is a placeholder (default false). */\n  keepGenericNames?: boolean\n  disabled?: boolean\n  labels?: Partial<PasteAreaLabels>\n}\n\n/**\n * A region that receives whatever is on the clipboard: a screenshot, an image, a file, a table\n * copied out of a spreadsheet, or plain text.\n *\n * Two ways in, because the two clipboard mechanisms are available under opposite conditions. The\n * `paste` event needs no permission and works everywhere, but only reaches the focused element —\n * which is why this region is focusable and why `global` exists. `navigator.clipboard.read()` can be\n * called whenever, but prompts for permission, is not implemented everywhere, and in some engines\n * only resolves inside a user gesture — which is why it sits behind a button, and why the button\n * appears only after mount, once there is a `navigator` to ask.\n */\nexport const PasteArea = React.forwardRef<HTMLDivElement, PasteAreaProps>(function PasteArea(\n  {\n    onPaste,\n    onReject,\n    accept,\n    maxSize,\n    maxFiles,\n    global: listenGlobally = false,\n    hideButton = false,\n    keepGenericNames = false,\n    disabled = false,\n    labels,\n    className,\n    children,\n    ...props\n  },\n  forwardedRef\n) {\n  const text = { ...defaultLabels, ...labels }\n\n  const regionRef = React.useRef<HTMLDivElement | null>(null)\n  React.useImperativeHandle(forwardedRef, () => regionRef.current as HTMLDivElement)\n\n  const [message, setMessage] = React.useState(\"\")\n  const [reading, setReading] = React.useState(false)\n  /**\n   * Whether this browser can be asked for the clipboard directly.\n   *\n   * Settled in an effect, never during render: the server has no `navigator`, so a button rendered\n   * from this check would be in the client's first tree and absent from the server's HTML, and React\n   * throws the whole tree away and re-renders it rather than reconciling that.\n   */\n  const [canRead, setCanRead] = React.useState(false)\n  React.useEffect(() => {\n    setCanRead(\n      typeof navigator !== \"undefined\" && typeof navigator.clipboard?.read === \"function\"\n    )\n  }, [])\n\n  const busyRef = React.useRef(false)\n  const mountedRef = React.useRef(true)\n  React.useEffect(\n    () => () => {\n      mountedRef.current = false\n    },\n    []\n  )\n\n  /** Continues across pastes, so two pastes in one second cannot mint the same filename. */\n  const sequenceRef = React.useRef(1)\n\n  const hintId = `${React.useId()}-hint`\n\n  /**\n   * Screen, name and hand on one paste. Returns whether anything was taken, which is what decides\n   * if the browser's own handling of the event is worth preventing.\n   */\n  const deliver = React.useCallback(\n    (raw: RawPaste, source: PasteSource): boolean => {\n      const { accepted, rejected } = screenFiles(raw.files, { accept, maxSize, maxFiles })\n      const files = normalizeFiles(accepted, {\n        stamp: stampFor(new Date()),\n        sequence: sequenceRef.current,\n        rename: !keepGenericNames,\n      })\n      sequenceRef.current += files.filter((entry) => entry.renamed).length\n\n      const payload: PastePayload = {\n        files,\n        text: raw.text,\n        html: raw.html,\n        types: raw.types,\n        source,\n      }\n      const took = files.length > 0 || raw.text !== null || raw.html !== null\n\n      if (rejected.length) onReject?.(rejected)\n      if (took) onPaste(payload)\n      setMessage(describePaste(payload, rejected) || text.empty)\n      return took\n    },\n    [accept, keepGenericNames, maxFiles, maxSize, onPaste, onReject, text.empty]\n  )\n\n  const handlePaste = React.useCallback(\n    (event: {\n      clipboardData: ClipboardSource | null\n      target: unknown\n      preventDefault: () => void\n    }) => {\n      if (disabled) return\n      // The paste belongs to a text box, here or elsewhere on the page. Leave it alone.\n      if (isEditableTarget(event.target)) return\n      if (deliver(readClipboard(event.clipboardData), \"event\")) event.preventDefault()\n    },\n    [deliver, disabled]\n  )\n\n  /**\n   * The document listener, kept behind a ref that is replaced every render so that turning `global`\n   * on does not re-subscribe the document on each keystroke elsewhere in the tree.\n   */\n  const globalHandler = React.useRef<(event: ClipboardEvent) => void>(() => {})\n  React.useEffect(() => {\n    globalHandler.current = handlePaste\n  })\n  React.useEffect(() => {\n    if (!listenGlobally || disabled) return\n    if (typeof document === \"undefined\" || !document.addEventListener) return\n    const listener = (event: ClipboardEvent) => globalHandler.current(event)\n    document.addEventListener(\"paste\", listener)\n    return () => document.removeEventListener(\"paste\", listener)\n  }, [listenGlobally, disabled])\n\n  const readClipboardDirectly = React.useCallback(async () => {\n    if (disabled || busyRef.current) return\n    busyRef.current = true\n    setReading(true)\n    setMessage(text.reading)\n    try {\n      const items = await navigator.clipboard.read()\n      const raw = await readClipboardItems(items as unknown as ClipboardItemLike[])\n      if (!mountedRef.current) return\n      deliver(raw, \"clipboard-api\")\n    } catch (error) {\n      if (!mountedRef.current) return\n      setMessage(isPermissionError(error) ? text.denied : text.failed)\n    } finally {\n      busyRef.current = false\n      if (mountedRef.current) setReading(false)\n    }\n  }, [deliver, disabled, text.denied, text.failed, text.reading])\n\n  /** Focus the region so the next Ctrl+V has somewhere to land — unless the click had its own target. */\n  const handleClick = React.useCallback(\n    (event: React.MouseEvent<HTMLDivElement>) => {\n      if (disabled || isFocusableTarget(event.target)) return\n      regionRef.current?.focus()\n    },\n    [disabled]\n  )\n\n  const showButton = canRead && !hideButton\n\n  return (\n    <div\n      ref={regionRef}\n      role=\"group\"\n      tabIndex={disabled ? -1 : 0}\n      aria-label={text.label}\n      /*\n        Only while the default hint is the thing being described. `children` replaces that hint, and\n        a describedby left pointing at an id no longer in the document describes nothing — the\n        attribute is silently dropped, so the region loses its instruction with no sign that it did.\n        A caller supplying their own content can name their own description: `props` is spread after\n        this and wins.\n      */\n      aria-describedby={children ? undefined : hintId}\n      aria-disabled={disabled || undefined}\n      onClick={handleClick}\n      /*\n        Left off entirely in global mode. A `paste` event bubbles to the document, so keeping both\n        this handler and the document listener would deliver every paste made while the region is\n        focused twice — two copies of the screenshot, from one Ctrl+V.\n      */\n      onPaste={listenGlobally ? undefined : handlePaste}\n      className={cn(\n        \"flex min-h-32 w-full flex-col items-center justify-center gap-2 rounded-lg border-2\",\n        \"border-dashed border-input bg-transparent p-6 text-center text-sm text-muted-foreground\",\n        \"transition-colors focus-visible:outline-none focus-visible:border-ring\",\n        \"focus-visible:ring-1 focus-visible:ring-ring\",\n        disabled ? \"cursor-not-allowed opacity-50\" : \"cursor-text hover:border-ring/60\",\n        className\n      )}\n      {...props}\n    >\n      {children ?? (\n        <>\n          <ClipboardPaste className=\"h-6 w-6\" aria-hidden=\"true\" />\n          <div id={hintId}>{text.hint}</div>\n        </>\n      )}\n      {showButton ? (\n        <button\n          type=\"button\"\n          onClick={readClipboardDirectly}\n          /*\n            `aria-disabled`, not `disabled`: a disabled button drops focus the moment it is pressed,\n            which would throw a keyboard user out of the region they are pasting into.\n          */\n          aria-disabled={reading || disabled}\n          className={cn(\n            \"mt-1 inline-flex items-center gap-1.5 rounded-md border border-input bg-background\",\n            \"px-2.5 py-1.5 text-sm font-medium text-foreground shadow-sm transition-colors\",\n            \"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\",\n            \"focus-visible:ring-offset-2\",\n            reading || disabled\n              ? \"cursor-not-allowed opacity-50\"\n              : \"hover:bg-accent hover:text-accent-foreground\"\n          )}\n        >\n          {reading ? (\n            <Loader2 className=\"size-4 animate-spin\" aria-hidden=\"true\" />\n          ) : (\n            <ClipboardPaste className=\"size-4\" aria-hidden=\"true\" />\n          )}\n          {text.button}\n        </button>\n      ) : null}\n      {/*\n        Rendered unconditionally so the region exists in the DOM before it has anything to say — one\n        inserted at the same moment as its text is commonly announced late, or not at all. Visible\n        rather than `sr-only`, because \"Clipboard permission denied\" is the answer to \"why did\n        nothing happen\", and sighted people ask that too.\n      */}\n      <span role=\"status\" aria-live=\"polite\" className=\"text-xs text-muted-foreground\">\n        {message}\n      </span>\n    </div>\n  )\n})\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui",
  "docs": "Install any pulld component by name: add \"@pulld\": \"https://pulld.pages.dev/r/{name}.json\" to the registries block in components.json, then `npx shadcn add @pulld/<name>`. All 140 components: https://pulld.pages.dev/?utm_source=cli"
}
