{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"copy-button","title":"Copy Button","description":"Copy a value to the clipboard with an icon button or a labeled button that confirms with a check, a tooltip and an announcement. Search terms: copy to clipboard, copyable, copy link, copy URL.","dependencies":["lucide-react@1.18.0"],"registryDependencies":["@siteplane/base","@siteplane/action-icons","@siteplane/button","@siteplane/tooltip"],"files":[{"path":"packages/ui/src/components/copy-button/copy-button.tsx","content":"\"use client\";\n\nimport { XIcon } from \"lucide-react\";\nimport * as React from \"react\";\n\nimport {\n  announceCopyStatus,\n  CopyRedrawIcon,\n  type CopyIconPhase,\n  useCopyIconPhase,\n} from \"../action-icons\";\nimport { Button, type ButtonProfile, type ButtonProps } from \"../button\";\nimport { Tooltip, TooltipPopup, TooltipTrigger } from \"../tooltip\";\n\n/** The words a CopyButton shows and speaks. */\nexport type CopyButtonLabels = {\n  /** The name and the tooltip of a button that shows the icon alone. */\n  copy: string;\n  /**\n   * The name, the tooltip and the announcement while the check confirms a\n   * copy; a button with text shows it in place of its label.\n   */\n  copied: string;\n  /**\n   * The same for a copy that failed: the clipboard is missing or refuses, or\n   * the value could not be read.\n   */\n  copyFailed: string;\n};\n\nconst defaultCopyButtonLabels: CopyButtonLabels = {\n  copy: \"Copy\",\n  copied: \"Copied\",\n  copyFailed: \"Copy unavailable\",\n};\n\n/**\n * The text to copy, or a function that returns it, also one that loads it\n * first, such as a token requested when the button is pressed.\n */\nexport type CopyValue = string | (() => string | Promise<string>);\n\n// How long a failed copy shows, in milliseconds: a little longer than the\n// check of a copy that worked (rulebook T7).\nconst copyFailureHold = 2400;\n\n/**\n * Copies `text` through a selection and the copy command, for a page without\n * the Clipboard API, such as one served over plain http. The text is selected\n * in a node of its own, so focus stays where it is. Inside a modal dialog the\n * node joins the dialog, since the page behind it may be inert.\n */\nfunction copyThroughSelection(text: string): boolean {\n  if (typeof document === \"undefined\") return false;\n  const selection = document.getSelection();\n  if (!selection || typeof document.execCommand !== \"function\") return false;\n  const ranges = Array.from({ length: selection.rangeCount }, (_, index) =>\n    selection.getRangeAt(index),\n  );\n  const node = document.createElement(\"span\");\n  node.textContent = text;\n  node.style.position = \"fixed\";\n  node.style.top = \"0\";\n  node.style.left = \"0\";\n  node.style.clipPath = \"inset(50%)\";\n  node.style.whiteSpace = \"pre\";\n  node.style.userSelect = \"text\";\n  const host =\n    document.activeElement?.closest(\n      \"dialog, [role='dialog'], [role='alertdialog']\",\n    ) ?? document.body;\n  host.append(node);\n  try {\n    const range = document.createRange();\n    range.selectNodeContents(node);\n    selection.removeAllRanges();\n    selection.addRange(range);\n    return document.execCommand(\"copy\");\n  } catch {\n    return false;\n  } finally {\n    selection.removeAllRanges();\n    for (const range of ranges) selection.addRange(range);\n    node.remove();\n  }\n}\n\nfunction writeText(text: string): Promise<void> {\n  const clipboard =\n    typeof navigator === \"undefined\" ? undefined : navigator.clipboard;\n  if (clipboard?.writeText) return clipboard.writeText(text);\n  return copyThroughSelection(text)\n    ? Promise.resolve()\n    : Promise.reject(new Error(\"The clipboard is unavailable.\"));\n}\n\n/**\n * Puts a value on the clipboard. It resolves with the text it copied, or\n * rejects when the clipboard is missing or refuses, or the value throws or\n * rejects. The write starts in the caller's event, so call it from a click: a\n * browser lets a page write to the clipboard only then. A value that is still\n * loading goes in as a promised ClipboardItem, the clipboard's way to write\n * data that is still on its way, so Safari keeps the click's permission while\n * the text arrives.\n */\nexport function copyToClipboard(value: CopyValue): Promise<string> {\n  let result: string | Promise<string>;\n  try {\n    result = typeof value === \"function\" ? value() : value;\n  } catch (error) {\n    return Promise.reject(error);\n  }\n  if (typeof result === \"string\") {\n    const text = result;\n    return writeText(text).then(() => text);\n  }\n  const pending = Promise.resolve(result);\n  const clipboard =\n    typeof navigator === \"undefined\" ? undefined : navigator.clipboard;\n  if (clipboard?.write && typeof ClipboardItem === \"function\") {\n    // The value's own error reaches the caller, not the browser's.\n    let valueError: unknown;\n    const blob = pending.then(\n      (text) => new Blob([text], { type: \"text/plain\" }),\n      (error: unknown) => {\n        valueError = error;\n        throw error;\n      },\n    );\n    let item: ClipboardItem | undefined;\n    try {\n      item = new ClipboardItem({ \"text/plain\": blob });\n    } catch {\n      // A browser that takes no promise in a ClipboardItem writes the text\n      // once it has arrived, below.\n    }\n    if (item)\n      return clipboard.write([item]).then(\n        () => pending,\n        (error: unknown) => Promise.reject(valueError ?? error),\n      );\n  }\n  return pending.then((text) => writeText(text).then(() => text));\n}\n\nexport type UseCopyToClipboardOptions = {\n  /**\n   * Whether a copy and a failure are said in the kit's shared live region.\n   * Turn it off where the caller says them itself, such as in a status line\n   * of its own, so a copy is announced once.\n   */\n  announce?: boolean | undefined;\n  /** The words of the announcements, `copied` and `copyFailed`. */\n  labels?: Partial<Pick<CopyButtonLabels, \"copied\" | \"copyFailed\">> | undefined;\n  /** Called with the text once it is on the clipboard. */\n  onCopy?: ((value: string) => void) | undefined;\n  /**\n   * Called with the error when the clipboard is missing or refuses, or the\n   * value throws or rejects.\n   */\n  onCopyError?: ((error: unknown) => void) | undefined;\n};\n\nexport type CopyToClipboardState = {\n  /** Copies a value; resolves `true` once it is on the clipboard. */\n  copy: (value: CopyValue) => Promise<boolean>;\n  /** True while the check confirms a copy, for two seconds. */\n  copied: boolean;\n  /** True for a moment after a copy failed. */\n  failed: boolean;\n  /** The phase that `CopyRedrawIcon` draws. */\n  phase: CopyIconPhase;\n};\n\n/**\n * The copy of CopyButton for a control that is not a CopyButton, such as a\n * menu item: it writes the value, times the check of the copy icon and says\n * the outcome in the shared live region. Call `copy` from the click.\n */\nexport function useCopyToClipboard({\n  announce = true,\n  labels,\n  onCopy,\n  onCopyError,\n}: UseCopyToClipboardOptions = {}): CopyToClipboardState {\n  const copiedLabel = labels?.copied ?? defaultCopyButtonLabels.copied;\n  const failedLabel = labels?.copyFailed ?? defaultCopyButtonLabels.copyFailed;\n  const { copyPhase, isShowingCheck, playCopyFeedback } = useCopyIconPhase({\n    announce,\n    announcement: copiedLabel,\n  });\n  // A count, so a failure after a failure shows for its full time again.\n  const [failures, setFailures] = React.useState(0);\n  React.useEffect(() => {\n    if (failures === 0) return;\n    const timer = setTimeout(() => setFailures(0), copyFailureHold);\n    return () => clearTimeout(timer);\n  }, [failures]);\n  const stopFailureAnnouncement = React.useRef<(() => void) | undefined>(\n    undefined,\n  );\n  React.useEffect(() => () => stopFailureAnnouncement.current?.(), []);\n\n  async function copy(value: CopyValue): Promise<boolean> {\n    let text: string;\n    try {\n      text = await copyToClipboard(value);\n    } catch (error) {\n      setFailures((count) => count + 1);\n      if (announce) {\n        stopFailureAnnouncement.current?.();\n        stopFailureAnnouncement.current = announceCopyStatus(\n          failedLabel,\n          copyFailureHold,\n        );\n      }\n      onCopyError?.(error);\n      return false;\n    }\n    setFailures(0);\n    playCopyFeedback();\n    onCopy?.(text);\n    return true;\n  }\n\n  return {\n    copy,\n    copied: isShowingCheck && failures === 0,\n    failed: failures > 0,\n    phase: copyPhase,\n  };\n}\n\n/** The props of the TooltipPopup of a CopyButton that shows the icon alone. */\nexport type CopyButtonTooltipProps = Omit<\n  React.ComponentProps<typeof TooltipPopup>,\n  \"children\"\n>;\n\ntype DistributiveOmit<T, K extends PropertyKey> = T extends unknown\n  ? Omit<T, K>\n  : never;\n\nexport type CopyButtonProps = DistributiveOmit<\n  ButtonProps,\n  \"children\" | \"onCopy\" | \"value\"\n> & {\n  /** The text to copy, or a function that returns it, also asynchronously. */\n  value: CopyValue;\n  /**\n   * A label after the icon, such as \"Copy link\", which makes a text button.\n   * Without one the button shows the icon alone, named by `labels.copy` or\n   * `aria-label`, with a tooltip.\n   */\n  children?: React.ReactNode;\n  /** Replaces the English words of the button. */\n  labels?: Partial<CopyButtonLabels> | undefined;\n  /** Called with the text once it is on the clipboard. */\n  onCopy?: ((value: string) => void) | undefined;\n  /**\n   * Called with the error when the clipboard is missing or refuses, or\n   * `value` throws or rejects.\n   */\n  onCopyError?: ((error: unknown) => void) | undefined;\n  /**\n   * The tooltip of a button that shows the icon alone, which says \"Copy\" and\n   * then \"Copied\". `false` turns it off; an object passes props to\n   * TooltipPopup, such as `side`.\n   */\n  tooltip?: boolean | CopyButtonTooltipProps | undefined;\n};\n\n/**\n * A button that copies a value to the clipboard: the kit Button with the copy\n * icon that turns into a check after a copy. By default it shows the icon\n * alone as the muted inline action (rulebook T2),\n * with a tooltip that says \"Copy\" and then \"Copied\"; with children it is a\n * text button whose label says \"Copied\" while the check shows. The shared\n * live region says every copy, and a copy that fails names the failure, with\n * a cross on a button that shows the icon alone. Button props pass through,\n * such as `variant`, `size` and `profile`; `profile=\"field-action\"` places it\n * inside a field.\n */\nexport function CopyButton({\n  \"aria-label\": ariaLabel,\n  children,\n  labels,\n  onClick,\n  onCopy,\n  onCopyError,\n  profile,\n  profileTone,\n  shape,\n  size,\n  tooltip = true,\n  value,\n  variant,\n  ...props\n}: CopyButtonProps): React.ReactElement {\n  const text = { ...defaultCopyButtonLabels, ...labels };\n  const { copy, copied, failed, phase } = useCopyToClipboard({\n    labels: text,\n    onCopy,\n    onCopyError,\n  });\n  const iconOnly = React.Children.toArray(children).length === 0;\n  // Without a profile or a variant it is the muted inline action (T2); a\n  // variant alone, such as outline, is that variant's Button.\n  const inline =\n    profile === \"inline\" || (profile === undefined && variant === undefined);\n  const look: ButtonProps = inline\n    ? {\n        profile: \"inline\",\n        profileTone: profileTone ?? \"muted\",\n        variant: variant ?? \"text\",\n      }\n    : {\n        ...(profile\n          ? { profile: profile as Exclude<ButtonProfile, \"inline\"> }\n          : {}),\n        variant,\n      };\n  const name = failed\n    ? text.copyFailed\n    : copied\n      ? text.copied\n      : (ariaLabel ?? text.copy);\n  const button = (\n    <Button\n      data-slot=\"copy-button\"\n      {...props}\n      {...look}\n      {...(shape ? { shape } : iconOnly ? { shape: \"square\" as const } : {})}\n      // The bare glyph keeps the 24 px target of the field action.\n      {...(size != null\n        ? { size }\n        : iconOnly && inline\n          ? { size: \"icon-xs\" as const }\n          : {})}\n      aria-label={iconOnly ? name : ariaLabel}\n      onClick={(event: React.MouseEvent<HTMLButtonElement>) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) void copy(value);\n      }}\n    >\n      {failed && iconOnly ? (\n        <XIcon aria-hidden=\"true\" className=\"sp-copy-fail sp-icon-pop size-4\" />\n      ) : (\n        <CopyRedrawIcon phase={phase} />\n      )}\n      {iconOnly\n        ? null\n        : failed\n          ? text.copyFailed\n          : copied\n            ? text.copied\n            : children}\n    </Button>\n  );\n  if (!iconOnly || tooltip === false) return button;\n\n  // The tooltip stays open on a click, so it can say \"Copied\". Its text is the\n  // trigger's payload, so the popup measures itself again when the text\n  // changes. It repeats the button's name, which assistive technology hears\n  // from the button, so it is hidden from it.\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        closeOnClick={false}\n        payload={name}\n        render={button as React.ReactElement<Record<string, unknown>>}\n      />\n      <TooltipPopup aria-hidden {...(tooltip === true ? {} : tooltip)}>\n        {name}\n      </TooltipPopup>\n    </Tooltip>\n  );\n}\n","type":"registry:ui","target":"~/src/siteplane/components/copy-button/copy-button.tsx"},{"path":"packages/ui/src/components/copy-button/index.ts","content":"export * from \"./copy-button\";\n","type":"registry:ui","target":"~/src/siteplane/components/copy-button/index.ts"}],"type":"registry:ui","docs":"https://ui.siteplane.io/docs-markdown/components/copy-button.md","categories":["Actions"],"meta":{"avoidWhen":["Avoid for a menu entry that copies; use a MenuItem with the Copy icon and copy in its onClick with useCopyToClipboard.","Avoid for a block of code; CodeBlock with copyable shows this button inside the block."],"description":"Copy a value to the clipboard with an icon button or a labeled button that confirms with a check, a tooltip and an announcement.","knownLimitations":["Browsers allow a page to write to the clipboard only in a secure context (https or localhost) and in response to a click; elsewhere the text is copied through a selection, which a browser may refuse.","A value that loads first must arrive while the browser still counts the click; a slow request can make the copy fail."],"providerNotes":["The tooltip is the installed Tooltip. A TooltipProvider sets its delay; without one it opens after Base UI's default delay."],"releaseStatus":"stable","searchTerms":["copy","clipboard","copy to clipboard","copy button","copyable","copy link","copy URL"],"useWhen":["Use beside a value people copy, such as a URL, an ID or an invite link, as the icon alone with a tooltip.","Use with a label, such as Copy link, for a copy action in a toolbar, a card or a dialog footer.","Use inside an InputGroup with profile=\"field-action\" for a read-only field that people copy from."]}}