Spaces:
Sleeping
Sleeping
| "use client"; | |
| import { useEffect, useRef, useState } from "react"; | |
| interface Props { | |
| text: string; | |
| /** Visible label, e.g. "Copy program". Pass empty string to render | |
| * icon-only (the visually-hidden `aria-label` still applies). */ | |
| label?: string; | |
| ariaLabel?: string; | |
| /** "default" = full bordered button with label; "icon" = compact | |
| * icon-only (for inside tiles). */ | |
| variant?: "default" | "icon"; | |
| /** Optional className override. */ | |
| className?: string; | |
| /** Click handler for the host to know a copy happened (e.g. analytics | |
| * or downstream UI). */ | |
| onCopied?: () => void; | |
| } | |
| /** | |
| * Reusable copy-to-clipboard button. | |
| * | |
| * Uses ``navigator.clipboard.writeText`` when available; degrades to a | |
| * hidden ``<textarea>`` + ``document.execCommand("copy")`` fallback when | |
| * the page isn't served over a secure context. | |
| * | |
| * Shows a brief "Copied" affordance after a successful copy. | |
| */ | |
| export default function CopyButton({ | |
| text, | |
| label = "Copy", | |
| ariaLabel, | |
| variant = "default", | |
| className, | |
| onCopied, | |
| }: Props) { | |
| const [copied, setCopied] = useState(false); | |
| const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | |
| useEffect(() => { | |
| return () => { | |
| if (timerRef.current) clearTimeout(timerRef.current); | |
| }; | |
| }, []); | |
| async function copy(e: React.MouseEvent) { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| let ok = false; | |
| try { | |
| if (navigator.clipboard?.writeText) { | |
| await navigator.clipboard.writeText(text); | |
| ok = true; | |
| } else { | |
| ok = legacyCopy(text); | |
| } | |
| } catch { | |
| ok = legacyCopy(text); | |
| } | |
| if (ok) { | |
| setCopied(true); | |
| if (timerRef.current) clearTimeout(timerRef.current); | |
| timerRef.current = setTimeout(() => setCopied(false), 1400); | |
| onCopied?.(); | |
| } | |
| } | |
| if (variant === "icon") { | |
| return ( | |
| <button | |
| type="button" | |
| onClick={copy} | |
| aria-label={ariaLabel ?? "Copy program text"} | |
| title={copied ? "Copied" : "Copy program text"} | |
| className={ | |
| className ?? | |
| "inline-flex h-5 w-5 items-center justify-center rounded text-muted hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent" | |
| } | |
| > | |
| {copied ? <CheckGlyph /> : <CopyGlyph />} | |
| </button> | |
| ); | |
| } | |
| return ( | |
| <button | |
| type="button" | |
| onClick={copy} | |
| aria-label={ariaLabel ?? label} | |
| className={ | |
| className ?? | |
| "inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2.5 py-1 text-[11px] font-medium text-ink hover:border-accent hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent" | |
| } | |
| > | |
| {copied ? <CheckGlyph /> : <CopyGlyph />} | |
| <span>{copied ? "Copied" : label}</span> | |
| </button> | |
| ); | |
| } | |
| function CopyGlyph() { | |
| return ( | |
| <svg | |
| aria-hidden | |
| width="12" | |
| height="12" | |
| viewBox="0 0 16 16" | |
| fill="none" | |
| stroke="currentColor" | |
| strokeWidth="1.4" | |
| > | |
| <rect x="5" y="3" width="9" height="11" rx="1.5" /> | |
| <path d="M3 11.5V3.5A1.5 1.5 0 0 1 4.5 2H11" /> | |
| </svg> | |
| ); | |
| } | |
| function CheckGlyph() { | |
| return ( | |
| <svg | |
| aria-hidden | |
| width="12" | |
| height="12" | |
| viewBox="0 0 16 16" | |
| fill="none" | |
| stroke="currentColor" | |
| strokeWidth="1.6" | |
| > | |
| <path d="M3 8.5L6.5 12L13 5" /> | |
| </svg> | |
| ); | |
| } | |
| function legacyCopy(text: string): boolean { | |
| try { | |
| const ta = document.createElement("textarea"); | |
| ta.value = text; | |
| ta.setAttribute("readonly", ""); | |
| ta.style.position = "fixed"; | |
| ta.style.opacity = "0"; | |
| document.body.appendChild(ta); | |
| ta.select(); | |
| const ok = document.execCommand("copy"); | |
| document.body.removeChild(ta); | |
| return ok; | |
| } catch { | |
| return false; | |
| } | |
| } | |