{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "copy-button",
  "title": "Copy Button",
  "description": "A copy-button component.",
  "dependencies": [
    "@hugeicons/react",
    "@hugeicons/core-free-icons"
  ],
  "registryDependencies": [
    "@cubby-ui/button",
    "@cubby-ui/toast"
  ],
  "files": [
    {
      "path": "registry/default/copy-button/copy-button.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { Button } from \"@/registry/default/button/button\";\nimport { cn } from \"@/lib/utils\";\nimport { useCopyToClipboard } from \"@/registry/default/copy-button/hooks/use-copy-to-clipboard\";\nimport {\n  toast as toastApi,\n  type AnchoredToastOptions,\n} from \"@/registry/default/toast/toast\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport {\n  Cancel01Icon,\n  CheckIcon,\n  Copy01Icon,\n} from \"@hugeicons/core-free-icons\";\n\ntype CopyButtonToastConfig = Omit<AnchoredToastOptions, \"anchor\">;\n\nconst DEFAULT_COPY_ICON = (\n  <HugeiconsIcon icon={Copy01Icon} strokeWidth={2} className=\"size-4\" />\n);\nconst DEFAULT_CHECK_ICON = (\n  <HugeiconsIcon\n    icon={CheckIcon}\n    strokeWidth={2}\n    className=\"size-4 text-green-500\"\n  />\n);\nconst DEFAULT_ERROR_ICON = (\n  <HugeiconsIcon\n    icon={Cancel01Icon}\n    strokeWidth={2}\n    className=\"size-4 text-red-500\"\n  />\n);\n\ninterface CopyButtonProps extends Omit<\n  React.ComponentProps<typeof Button>,\n  \"onClick\" | \"children\" | \"size\" | \"variant\"\n> {\n  content: string;\n  timeout?: number;\n  copyIcon?: React.ReactNode;\n  checkIcon?: React.ReactNode;\n  errorIcon?: React.ReactNode;\n  onCopied?: (text: string) => void;\n  onCopyError?: (text: string) => void;\n  /**\n   * Show an anchored toast above the button on successful copy.\n   * Pass `true` for defaults, or an options object to customize the toast.\n   */\n  toast?: true | CopyButtonToastConfig;\n}\n\nfunction CopyButton({\n  content,\n  timeout = 2000,\n  className,\n  copyIcon,\n  checkIcon,\n  errorIcon,\n  onCopied,\n  onCopyError,\n  toast,\n  ref,\n  ...props\n}: CopyButtonProps) {\n  const internalRef = React.useRef<HTMLButtonElement>(null);\n  const toastEnabled = Boolean(toast);\n  const toastConfig: CopyButtonToastConfig =\n    toast === true ? {} : (toast ?? {});\n\n  const { isCopied, isError, copyToClipboard, reset } = useCopyToClipboard({\n    // When an anchored toast is attached, the toast's lifecycle owns the\n    // reset via `onClose` — so disable the hook's internal auto-reset.\n    timeout: toastEnabled ? null : timeout,\n    onCopied: (text) => {\n      onCopied?.(text);\n      if (toastEnabled) {\n        toastApi.anchored({\n          description: \"Copied to clipboard!\",\n          side: \"top\",\n          sideOffset: 8,\n          arrow: true,\n          duration: timeout,\n          ...toastConfig,\n          anchor: internalRef,\n          onClose: () => {\n            reset();\n            toastConfig.onClose?.();\n          },\n        });\n      }\n    },\n    onCopyError: (text) => {\n      onCopyError?.(text);\n      if (toastEnabled) {\n        toastApi.anchored({\n          side: \"top\",\n          sideOffset: 8,\n          arrow: true,\n          duration: timeout,\n          ...toastConfig,\n          // The configurable description is the success message — the error\n          // toast always states the failure.\n          description: \"Failed to copy to clipboard\",\n          anchor: internalRef,\n          onClose: () => {\n            reset();\n            toastConfig.onClose?.();\n          },\n        });\n      }\n    },\n  });\n\n  const mergedRef = React.useCallback(\n    (node: HTMLButtonElement | null) => {\n      internalRef.current = node;\n      if (typeof ref === \"function\") ref(node);\n      else if (ref) ref.current = node;\n    },\n    [ref],\n  );\n\n  const button = (\n    <Button\n      ref={mergedRef}\n      data-slot=\"copy-button\"\n      size=\"icon_xs\"\n      variant=\"ghost\"\n      data-copied={isCopied || undefined}\n      data-error={isError || undefined}\n      disabled={isCopied}\n      // Keeps focus on the button through the copied window. Without it the\n      // browser blurs a disabled element, so a keyboard user is dropped to\n      // <body> the instant their own keypress succeeds. Base UI trades the\n      // native `disabled` attribute for aria-disabled to do that, which leaves\n      // the element clickable — it blocks the keyboard path itself, and the\n      // guard below is the pointer half.\n      focusableWhenDisabled\n      onClick={() => {\n        if (isCopied) return;\n        copyToClipboard(content);\n      }}\n      className={cn(\n        \"text-muted-foreground size-auto rounded-md p-1.5\",\n        className,\n      )}\n      // Deliberately fixed while the state underneath is not. Focus stays here\n      // now, so a label that rewrote itself mid-copy would be a second\n      // announcement racing the live region below, and which of the two a\n      // screen reader reads is up to the screen reader. The label names the\n      // control, the region reports the outcome.\n      aria-label=\"Copy to clipboard\"\n      title={isCopied ? \"Copied!\" : isError ? \"Copy failed\" : \"Copy\"}\n      {...props}\n    >\n      {/* The three icons crossfade in place, so they share one grid cell.\n          This wrapper owns that grid rather than styling Button's internal\n          content span through `[&>span]`. That span is Button's private DOM,\n          and reaching for it is not a theoretical risk: it did not exist until\n          the Button redesign added it, so for every release before that one\n          `[&>span]` matched these three icons themselves and stacked nothing —\n          they sat side by side, two of them invisible but still taking width.\n          The selector did not break, it silently changed which element it\n          meant, in both directions, unnoticed.\n\n          `grid-template-areas` on the Button root was inert throughout: the\n          root is `inline-flex`, so it is not a grid. Declaring the area here\n          is what lets `[grid-area:stack]` below resolve by name rather than\n          through the implicit-line fallback it has been leaning on. */}\n      <span\n        data-slot=\"copy-button-stack\"\n        className=\"grid place-items-center [grid-template-areas:'stack']\"\n      >\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"flex items-center justify-center blur-none transition-[scale,opacity,filter] duration-300 [grid-area:stack]\",\n            (isCopied || isError) && \"scale-50 opacity-0 blur-xs\",\n          )}\n        >\n          {copyIcon ?? DEFAULT_COPY_ICON}\n        </span>\n\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"flex scale-50 items-center justify-center opacity-0 blur-xs transition-[scale,opacity,filter] duration-300 [grid-area:stack]\",\n            isCopied && \"scale-100 opacity-100 blur-none\",\n          )}\n        >\n          {checkIcon ?? DEFAULT_CHECK_ICON}\n        </span>\n\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"flex scale-50 items-center justify-center opacity-0 blur-xs transition-[scale,opacity,filter] duration-300 [grid-area:stack]\",\n            isError && \"scale-100 opacity-100 blur-none\",\n          )}\n        >\n          {errorIcon ?? DEFAULT_ERROR_ICON}\n        </span>\n      </span>\n    </Button>\n  );\n\n  // The icons are aria-hidden and the label is fixed, so this is the only\n  // thing that reports the outcome. Two rules govern where it can live.\n  //\n  // Mounted for the whole life of the button, never conditionally rendered\n  // around the result: a region that arrives already holding its text is not a\n  // change, and screen readers announce the change.\n  //\n  // And a SIBLING of the button, not a child. `role=\"button\"` is Children\n  // Presentational, so a conforming reader prunes the semantics of everything\n  // inside it — a live region in there is not a live region. This is why the\n  // fixed `aria-label` above is safe: the region genuinely does the reporting.\n  //\n  // Suppressed entirely when a toast is attached, which says the same words out\n  // loud on its own.\n  //\n  // `display: contents` rather than a bare fragment, so the component still\n  // resolves to one element for anything that counts them — `Children.only`,\n  // a `render` prop, `:only-child`, `> * + *`. It adds no box, so it creates no\n  // containing block and the absolutely-positioned floating variant in\n  // CodeBlock still resolves against the same ancestor it did before.\n  return (\n    <span className=\"contents\">\n      {button}\n      {!toastEnabled && (\n        <span role=\"status\" className=\"sr-only\">\n          {isCopied\n            ? \"Copied to clipboard\"\n            : isError\n              ? \"Failed to copy to clipboard. Copy it manually.\"\n              : \"\"}\n        </span>\n      )}\n    </span>\n  );\n}\n\nexport { CopyButton };\n",
      "type": "registry:ui",
      "target": "components/ui/cubby-ui/copy-button/copy-button.tsx"
    },
    {
      "path": "registry/default/copy-button/hooks/use-copy-to-clipboard.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useState } from \"react\";\n\nasync function writeToClipboard(text: string): Promise<boolean> {\n  try {\n    await navigator.clipboard.writeText(text);\n    return true;\n  } catch {\n    try {\n      const textarea = document.createElement(\"textarea\");\n      textarea.value = text;\n      textarea.style.position = \"fixed\";\n      textarea.style.opacity = \"0\";\n      document.body.appendChild(textarea);\n      textarea.select();\n\n      const success = document.execCommand(\"copy\");\n      document.body.removeChild(textarea);\n\n      return success;\n    } catch {\n      return false;\n    }\n  }\n}\n\nexport interface UseCopyToClipboardOptions {\n  /** ms before `isCopied`/`isError` auto-resets. Pass `null` to disable (e.g. when another mechanism owns the lifecycle). */\n  timeout?: number | null;\n  onCopied?: (text: string) => void;\n  onCopyError?: (text: string) => void;\n}\n\nexport function useCopyToClipboard({\n  timeout = 2000,\n  onCopied,\n  onCopyError,\n}: UseCopyToClipboardOptions = {}) {\n  const [status, setStatus] = useState<\"idle\" | \"copied\" | \"error\">(\"idle\");\n\n  useEffect(() => {\n    if (status !== \"idle\" && timeout != null) {\n      const timer = setTimeout(() => setStatus(\"idle\"), timeout);\n      return () => clearTimeout(timer);\n    }\n  }, [status, timeout]);\n\n  const copyToClipboard = useCallback(\n    async (text: string): Promise<boolean> => {\n      const success = await writeToClipboard(text);\n      if (success) {\n        setStatus(\"copied\");\n        onCopied?.(text);\n      } else {\n        setStatus(\"error\");\n        onCopyError?.(text);\n      }\n      return success;\n    },\n    [onCopied, onCopyError],\n  );\n\n  const reset = useCallback(() => setStatus(\"idle\"), []);\n\n  return {\n    isCopied: status === \"copied\",\n    isError: status === \"error\",\n    copyToClipboard,\n    reset,\n  };\n}\n",
      "type": "registry:hook",
      "target": "components/ui/cubby-ui/copy-button/hooks/use-copy-to-clipboard.ts"
    }
  ],
  "type": "registry:ui"
}