144 lines
3.8 KiB
TypeScript
144 lines
3.8 KiB
TypeScript
|
|
// components/CopyButton.tsx
|
||
|
|
// Tiny client component that copies a string to the clipboard with a
|
||
|
|
// transient "Copied" confirmation. Used on read-only detail pages
|
||
|
|
// (e.g. SARS eFiling credentials) where selecting individual
|
||
|
|
// characters by hand is fiddly. Falls back to the legacy textarea
|
||
|
|
// trick when navigator.clipboard is unavailable (e.g. insecure
|
||
|
|
// context) so it still works on http://localhost.
|
||
|
|
|
||
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState } from "react";
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
/** The string to copy when the user clicks. */
|
||
|
|
value: string;
|
||
|
|
/** Accessible label for the button (also shown as the tooltip
|
||
|
|
* in the idle state). Defaults to "Copy". */
|
||
|
|
label?: string;
|
||
|
|
/** Optional extra classes appended to the button. */
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
type State = "idle" | "copied" | "error";
|
||
|
|
|
||
|
|
export default function CopyButton({
|
||
|
|
value,
|
||
|
|
label = "Copy",
|
||
|
|
className = "",
|
||
|
|
}: Props) {
|
||
|
|
const [state, setState] = useState<State>("idle");
|
||
|
|
|
||
|
|
async function handleClick() {
|
||
|
|
try {
|
||
|
|
if (navigator.clipboard?.writeText) {
|
||
|
|
await navigator.clipboard.writeText(value);
|
||
|
|
} else {
|
||
|
|
// Legacy fallback for browsers that block the async Clipboard
|
||
|
|
// API (e.g. served over plain http://). Mirrors the pattern
|
||
|
|
// used in ShareLinkButton so behaviour is consistent.
|
||
|
|
const ta = document.createElement("textarea");
|
||
|
|
ta.value = value;
|
||
|
|
ta.setAttribute("readonly", "");
|
||
|
|
ta.style.position = "fixed";
|
||
|
|
ta.style.opacity = "0";
|
||
|
|
document.body.appendChild(ta);
|
||
|
|
ta.select();
|
||
|
|
document.execCommand("copy");
|
||
|
|
document.body.removeChild(ta);
|
||
|
|
}
|
||
|
|
setState("copied");
|
||
|
|
} catch {
|
||
|
|
setState("error");
|
||
|
|
}
|
||
|
|
// Revert after a short delay so the icon change is noticeable
|
||
|
|
// without sticking around and confusing later clicks.
|
||
|
|
window.setTimeout(() => setState("idle"), 1800);
|
||
|
|
}
|
||
|
|
|
||
|
|
const title =
|
||
|
|
state === "copied"
|
||
|
|
? "Copied!"
|
||
|
|
: state === "error"
|
||
|
|
? "Copy failed — try selecting manually"
|
||
|
|
: label;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={handleClick}
|
||
|
|
title={title}
|
||
|
|
aria-label={title}
|
||
|
|
aria-live="polite"
|
||
|
|
className={
|
||
|
|
"inline-flex items-center justify-center w-7 h-7 rounded-md text-muted hover:text-fg hover:bg-surface-warm active:bg-surface-warm transition-colors " +
|
||
|
|
className
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{state === "copied" ? (
|
||
|
|
<IconCheck className="w-4 h-4 text-success" />
|
||
|
|
) : state === "error" ? (
|
||
|
|
<IconAlert className="w-4 h-4 text-danger" />
|
||
|
|
) : (
|
||
|
|
<IconCopy className="w-4 h-4" />
|
||
|
|
)}
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function IconCopy({ className }: { className?: string }) {
|
||
|
|
return (
|
||
|
|
<svg
|
||
|
|
viewBox="0 0 24 24"
|
||
|
|
fill="none"
|
||
|
|
stroke="currentColor"
|
||
|
|
strokeWidth={1.8}
|
||
|
|
strokeLinecap="round"
|
||
|
|
strokeLinejoin="round"
|
||
|
|
className={className}
|
||
|
|
aria-hidden="true"
|
||
|
|
>
|
||
|
|
<rect x="9" y="9" width="11" height="11" rx="2" />
|
||
|
|
<path d="M5 15V5a2 2 0 0 1 2-2h10" />
|
||
|
|
</svg>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function IconCheck({ className }: { className?: string }) {
|
||
|
|
return (
|
||
|
|
<svg
|
||
|
|
viewBox="0 0 24 24"
|
||
|
|
fill="none"
|
||
|
|
stroke="currentColor"
|
||
|
|
strokeWidth={2}
|
||
|
|
strokeLinecap="round"
|
||
|
|
strokeLinejoin="round"
|
||
|
|
className={className}
|
||
|
|
aria-hidden="true"
|
||
|
|
>
|
||
|
|
<path d="M5 12.5l4.5 4.5L19 7" />
|
||
|
|
</svg>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function IconAlert({ className }: { className?: string }) {
|
||
|
|
return (
|
||
|
|
<svg
|
||
|
|
viewBox="0 0 24 24"
|
||
|
|
fill="none"
|
||
|
|
stroke="currentColor"
|
||
|
|
strokeWidth={1.8}
|
||
|
|
strokeLinecap="round"
|
||
|
|
strokeLinejoin="round"
|
||
|
|
className={className}
|
||
|
|
aria-hidden="true"
|
||
|
|
>
|
||
|
|
<path d="M12 9v4" />
|
||
|
|
<path d="M12 17h.01" />
|
||
|
|
<path
|
||
|
|
d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z"
|
||
|
|
/>
|
||
|
|
</svg>
|
||
|
|
);
|
||
|
|
}
|