Spaces:
Sleeping
Sleeping
File size: 3,838 Bytes
0fff343 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 | "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;
}
}
|