{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "copy-button",
  "title": "Copy Button",
  "author": "AlgorizeTech",
  "description": "A clipboard button that confirms the copy in place — the icon pops to a check and the label swaps to \"Copied!\" — plus a read-only Copy Field for links, tokens and commands. Use it anywhere a value needs to be copied without a toast.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "src/components/custom/blocks/CopyButtonBlock.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Check, Copy, X } from \"lucide-react\";\nimport React, { forwardRef, useState } from \"react\";\nimport { useCopyToClipboard } from \"./copy-button-hooks\";\n\nexport type { CopyStatus } from \"./copy-button-hooks\";\n\n// ==========================================\n// THEME\n// ==========================================\nexport type CopyButtonColor = \"primary\" | \"indigo\" | \"rose\" | \"emerald\" | \"zinc\" | string;\nexport type CopyButtonVariant = \"solid\" | \"outline\" | \"ghost\" | \"soft\";\nexport type CopyButtonSize = \"sm\" | \"md\" | \"lg\";\n\nconst colorThemes: Record<string, Record<string, string>> = {\n  primary: {\n    solid: \"bg-primary text-primary-foreground hover:bg-primary/90\",\n    soft: \"bg-primary/10 text-primary hover:bg-primary/15\",\n    text: \"text-primary\",\n    ring: \"focus-visible:ring-primary/30\",\n  },\n  indigo: {\n    solid: \"bg-indigo-600 text-white hover:bg-indigo-700\",\n    soft: \"bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 hover:bg-indigo-500/15\",\n    text: \"text-indigo-600 dark:text-indigo-400\",\n    ring: \"focus-visible:ring-indigo-500/30\",\n  },\n  rose: {\n    solid: \"bg-rose-600 text-white hover:bg-rose-700\",\n    soft: \"bg-rose-500/10 text-rose-600 dark:text-rose-400 hover:bg-rose-500/15\",\n    text: \"text-rose-600 dark:text-rose-400\",\n    ring: \"focus-visible:ring-rose-500/30\",\n  },\n  emerald: {\n    solid: \"bg-emerald-600 text-white hover:bg-emerald-700\",\n    soft: \"bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/15\",\n    text: \"text-emerald-600 dark:text-emerald-400\",\n    ring: \"focus-visible:ring-emerald-500/30\",\n  },\n  zinc: {\n    solid: \"bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-white dark:text-zinc-900 dark:hover:bg-zinc-200\",\n    soft: \"bg-zinc-500/10 text-zinc-900 dark:text-zinc-100 hover:bg-zinc-500/15\",\n    text: \"text-zinc-900 dark:text-zinc-100\",\n    ring: \"focus-visible:ring-zinc-500/30\",\n  },\n};\n\nconst getTheme = (color: string) => {\n  if (color.startsWith(\"#\")) {\n    return {\n      theme: {\n        solid: \"bg-[var(--copy-color)] text-white hover:opacity-90\",\n        soft: \"bg-[color-mix(in_oklab,var(--copy-color)_12%,transparent)] text-[var(--copy-color)] hover:bg-[color-mix(in_oklab,var(--copy-color)_18%,transparent)]\",\n        text: \"text-[var(--copy-color)]\",\n        ring: \"focus-visible:ring-[color-mix(in_oklab,var(--copy-color)_30%,transparent)]\",\n      },\n      style: { \"--copy-color\": color } as React.CSSProperties,\n    };\n  }\n  return { theme: colorThemes[color] ?? colorThemes.primary, style: {} };\n};\n\n// Keyframes ship inline so the block stays self-contained — no plugin, no\n// globals.css entry. Names are prefixed to avoid clashing with app styles.\nconst KEYFRAMES = `\n@keyframes copy-btn-pop {\n  0% { transform: scale(0.4) rotate(-20deg); opacity: 0; }\n  60% { transform: scale(1.2) rotate(4deg); opacity: 1; }\n  100% { transform: scale(1) rotate(0); opacity: 1; }\n}\n@keyframes copy-btn-rise {\n  from { transform: translateY(0.45em); opacity: 0; }\n  to { transform: translateY(0); opacity: 1; }\n}`;\n\n// ==========================================\n// COPY BUTTON\n// ==========================================\nexport interface CopyButtonProps\n  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, \"value\" | \"onCopy\" | \"color\"> {\n  /** The text that lands on the clipboard. */\n  value: string;\n  /** Resting label. Omit for an icon-only button. Default \"Copy\". */\n  label?: React.ReactNode;\n  /** Label shown after a successful copy. Default \"Copied!\". */\n  copiedLabel?: React.ReactNode;\n  /** Label shown when the clipboard write fails. Default \"Failed\". */\n  errorLabel?: React.ReactNode;\n  /** Hide the label entirely and render a square icon button. */\n  iconOnly?: boolean;\n  /** How long the copied state lingers, in ms. Default 2000. */\n  timeout?: number;\n  variant?: CopyButtonVariant;\n  size?: CopyButtonSize;\n  /** Theme colour — a preset name or any hex code. */\n  color?: CopyButtonColor;\n  /** Fires after the clipboard write with the value and whether it succeeded. */\n  onCopy?: (value: string, ok: boolean) => void;\n}\n\nexport const CopyButton = forwardRef<HTMLButtonElement, CopyButtonProps>(\n  (\n    {\n      value,\n      label = \"Copy\",\n      copiedLabel = \"Copied!\",\n      errorLabel = \"Failed\",\n      iconOnly = false,\n      timeout = 2000,\n      variant = \"solid\",\n      size = \"md\",\n      color = \"primary\",\n      onCopy,\n      className,\n      style,\n      onClick,\n      disabled,\n      ...props\n    },\n    ref,\n  ) => {\n    const { copy, status } = useCopyToClipboard(timeout);\n    const { theme, style: themeStyle } = getTheme(color);\n    // No entrance animation on first paint — the pop is for state changes only,\n    // so it switches on with the first one (and stays on, so idle pops back too).\n    const [interacted, setInteracted] = useState(false);\n    if (!interacted && status !== \"idle\") setInteracted(true);\n\n    const sizes = {\n      sm: { btn: \"h-8 text-xs gap-1.5 rounded-md\", pad: \"px-2.5\", square: \"w-8\", icon: \"w-3.5 h-3.5\" },\n      md: { btn: \"h-10 text-sm gap-2 rounded-md\", pad: \"px-4\", square: \"w-10\", icon: \"w-4 h-4\" },\n      lg: { btn: \"h-12 text-base gap-2 rounded-lg\", pad: \"px-5\", square: \"w-12\", icon: \"w-5 h-5\" },\n    }[size];\n\n    const variants = {\n      solid: theme.solid,\n      soft: theme.soft,\n      outline: cn(\n        \"border border-border bg-background text-foreground hover:bg-muted\",\n      ),\n      ghost: \"bg-transparent text-foreground hover:bg-muted\",\n    };\n\n    const isCopied = status === \"copied\";\n    const isError = status === \"error\";\n    const currentLabel = isCopied ? copiedLabel : isError ? errorLabel : label;\n    const Icon = isCopied ? Check : isError ? X : Copy;\n\n    return (\n      <>\n      {/* Keyframes live beside the button, not inside it, so the live region\n          and textContent stay clean. */}\n      <style>{KEYFRAMES}</style>\n      <button\n        ref={ref}\n        type=\"button\"\n        disabled={disabled}\n        aria-live=\"polite\"\n        aria-label={iconOnly ? (isCopied ? \"Copied\" : \"Copy to clipboard\") : undefined}\n        data-state={status}\n        onClick={(e) => {\n          onClick?.(e);\n          if (e.defaultPrevented) return;\n          void copy(value).then((ok) => onCopy?.(value, ok));\n        }}\n        className={cn(\n          \"relative inline-flex shrink-0 items-center justify-center font-semibold whitespace-nowrap select-none transition-all duration-200 active:scale-95 outline-none focus-visible:ring-4 disabled:opacity-50 disabled:pointer-events-none\",\n          sizes.btn,\n          iconOnly ? sizes.square : sizes.pad,\n          variants[variant],\n          theme.ring,\n          // Success flashes the accent, whatever the resting variant is.\n          isCopied && variant !== \"solid\" && cn(theme.text),\n          isError && \"text-red-600 dark:text-red-400\",\n          className,\n        )}\n        style={{ ...themeStyle, ...style }}\n        {...props}\n      >\n        {/* Swapping the element (via key) restarts the pop each time. */}\n        <Icon\n          key={status}\n          className={cn(sizes.icon, \"shrink-0\")}\n          style={interacted ? { animation: \"copy-btn-pop 0.35s cubic-bezier(0.34,1.56,0.64,1) both\" } : undefined}\n          strokeWidth={isCopied ? 3 : 2}\n        />\n        {!iconOnly && (\n          <span\n            key={`label-${status}`}\n            className=\"inline-block\"\n            style={interacted ? { animation: \"copy-btn-rise 0.25s ease-out both\" } : undefined}\n          >\n            {currentLabel}\n          </span>\n        )}\n      </button>\n      </>\n    );\n  },\n);\nCopyButton.displayName = \"CopyButton\";\n\n// ==========================================\n// COPY FIELD (read-only input + button)\n// ==========================================\nexport interface CopyFieldProps\n  extends Omit<React.HTMLAttributes<HTMLDivElement>, \"color\" | \"prefix\"> {\n  /** The text shown in the field and copied by the button. */\n  value: string;\n  label?: string;\n  /** Props forwarded to the embedded button (label, variant, colour…). */\n  buttonProps?: Omit<CopyButtonProps, \"value\">;\n  size?: CopyButtonSize;\n  color?: CopyButtonColor;\n  /** Render the button inside the field's right edge instead of beside it. */\n  inset?: boolean;\n  /** Select the whole value when the field is clicked. Default true. */\n  selectOnClick?: boolean;\n  /** Left-side adornment inside the field, e.g. a link icon. */\n  prefix?: React.ReactNode;\n}\n\nexport const CopyField = forwardRef<HTMLDivElement, CopyFieldProps>(\n  (\n    {\n      value,\n      label,\n      buttonProps,\n      size = \"md\",\n      color = \"primary\",\n      inset = false,\n      selectOnClick = true,\n      prefix,\n      className,\n      ...props\n    },\n    ref,\n  ) => {\n    const heights = { sm: \"h-8 text-xs\", md: \"h-10 text-sm\", lg: \"h-12 text-base\" }[size];\n    const { theme, style } = getTheme(color);\n\n    return (\n      <div ref={ref} className={cn(\"flex w-full flex-col gap-1.5\", className)} {...props}>\n        {label && <span className=\"text-sm font-semibold text-foreground\">{label}</span>}\n        <div className={cn(\"flex w-full items-center\", inset ? \"relative\" : \"gap-2\")}>\n          {prefix && !inset && (\n            <span className=\"shrink-0 text-muted-foreground\">{prefix}</span>\n          )}\n          <input\n            readOnly\n            value={value}\n            onClick={selectOnClick ? (e) => e.currentTarget.select() : undefined}\n            className={cn(\n              \"w-full min-w-0 rounded-md border border-border bg-background px-3 font-mono text-foreground outline-none transition-shadow focus-visible:ring-4\",\n              theme.ring,\n              heights,\n              inset && (size === \"sm\" ? \"pr-10\" : size === \"lg\" ? \"pr-14\" : \"pr-12\"),\n              prefix && inset && \"pl-9\",\n            )}\n            style={style}\n          />\n          {prefix && inset && (\n            <span className=\"pointer-events-none absolute left-3 text-muted-foreground [&>svg]:w-4 [&>svg]:h-4\">\n              {prefix}\n            </span>\n          )}\n          <CopyButton\n            value={value}\n            size={size}\n            color={color}\n            {...(inset\n              ? {\n                  iconOnly: true,\n                  variant: \"ghost\" as const,\n                  className: cn(\"absolute right-1\", size === \"sm\" ? \"h-6 w-6\" : size === \"lg\" ? \"h-10 w-10\" : \"h-8 w-8\"),\n                }\n              : {})}\n            {...buttonProps}\n          />\n        </div>\n      </div>\n    );\n  },\n);\nCopyField.displayName = \"CopyField\";\n",
      "type": "registry:component",
      "target": "components/custom/blocks/CopyButtonBlock.tsx"
    },
    {
      "path": "src/components/custom/blocks/copy-button-hooks.ts",
      "content": "import { useCallback, useEffect, useRef, useState } from \"react\";\n\n// The clipboard hook behind CopyButton / CopyField, kept out of the component\n// file so that file stays a components-only module (React Fast Refresh).\n\nexport type CopyStatus = \"idle\" | \"copied\" | \"error\";\n\n/**\n * Copies text to the clipboard and reports a status that resets itself after\n * `timeout` ms. Falls back to a hidden textarea + execCommand where the async\n * Clipboard API is unavailable (older browsers, non-secure origins).\n */\nexport function useCopyToClipboard(timeout = 2000) {\n  const [status, setStatus] = useState<CopyStatus>(\"idle\");\n  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(\n    () => () => {\n      if (timer.current) clearTimeout(timer.current);\n    },\n    [],\n  );\n\n  const copy = useCallback(\n    async (text: string) => {\n      let ok = false;\n      try {\n        if (typeof navigator !== \"undefined\" && navigator.clipboard?.writeText) {\n          await navigator.clipboard.writeText(text);\n          ok = true;\n        }\n      } catch {\n        ok = false;\n      }\n      if (!ok && typeof document !== \"undefined\") {\n        const ta = document.createElement(\"textarea\");\n        ta.value = text;\n        ta.setAttribute(\"readonly\", \"\");\n        ta.style.position = \"fixed\";\n        ta.style.opacity = \"0\";\n        document.body.appendChild(ta);\n        ta.select();\n        try {\n          ok = document.execCommand(\"copy\");\n        } catch {\n          ok = false;\n        }\n        document.body.removeChild(ta);\n      }\n      setStatus(ok ? \"copied\" : \"error\");\n      if (timer.current) clearTimeout(timer.current);\n      timer.current = setTimeout(() => setStatus(\"idle\"), timeout);\n      return ok;\n    },\n    [timeout],\n  );\n\n  return { copy, status, copied: status === \"copied\" };\n}\n",
      "type": "registry:component",
      "target": "components/custom/blocks/copy-button-hooks.ts"
    }
  ],
  "docs": "Named exports: CopyButton, CopyField; the useCopyToClipboard hook is exported from copy-button-hooks.ts, which installs alongside. `value` is the text copied; `label` / `copiedLabel` / `errorLabel` set the three states, `timeout` how long the confirmation lingers, `iconOnly` drops the label. Variants are solid | soft | outline | ghost, sizes sm | md | lg, `color` a preset or hex. CopyField wraps a read-only input with the button beside it (or `inset` inside it) and forwards `buttonProps`. Falls back to execCommand where the Clipboard API is unavailable.",
  "categories": [
    "actions",
    "forms"
  ],
  "type": "registry:component"
}
