Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 135 additions & 2 deletions src/components/CopyButton.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import { render } from "../test/utils";
import CopyButton from "./CopyButton";

describe("CopyButton", () => {
Expand All @@ -25,11 +26,120 @@ describe("CopyButton", () => {
expect(navigator.clipboard.writeText).toHaveBeenCalledWith("0xabc123");
});

expect(screen.getByRole("button", { name: "Copy hash" })).toHaveTextContent(
expect(screen.getByRole("button", { name: /Copy hash/ })).toHaveTextContent(
"Copied"
);
});

it("shows check icon when copy succeeds", async () => {
render(<CopyButton value="test-value" label="Copy" />);

fireEvent.click(screen.getByRole("button", { name: "Copy" }));

await waitFor(() => {
// The check icon is rendered inside the button with aria-hidden
const button = screen.getByRole("button", { name: /Copy/ });
expect(button.querySelector("svg")).toBeTruthy();
});
});

it("updates aria-label to include copied state for screen readers", async () => {
render(<CopyButton value="test-value" label="Copy" copiedLabel="Copied!" />);

fireEvent.click(screen.getByRole("button", { name: "Copy" }));

await waitFor(() => {
expect(
screen.getByRole("button", { name: "Copy - Copied!" })
).toBeInTheDocument();
});
});

it("announces copy status to screen readers via aria-live region", async () => {
render(<CopyButton value="test-value" label="Copy" />);

fireEvent.click(screen.getByRole("button", { name: "Copy" }));

await waitFor(() => {
const liveRegion = screen.getByRole("status");
expect(liveRegion).toHaveAttribute("aria-live", "polite");
expect(liveRegion).toHaveTextContent("Copied to clipboard");
});
});

it("reverts to original label after success duration", async () => {
render(
<CopyButton
value="test-value"
label="Copy"
copiedLabel="Copied!"
successDurationMs={100}
/>
);

fireEvent.click(screen.getByRole("button", { name: "Copy" }));

await waitFor(() => {
expect(
screen.getByRole("button", { name: "Copy - Copied!" })
).toHaveTextContent("Copied!");
});

// Wait for the success duration to expire and label to revert
await waitFor(
() => {
expect(screen.getByRole("button", { name: "Copy" })).toHaveTextContent(
"Copy"
);
},
{ timeout: 3000 }
);
});

it("handles rapid repeated clicks without visual glitches", async () => {
render(
<CopyButton
value="test-value"
label="Copy"
copiedLabel="Copied!"
successDurationMs={200}
/>
);

const button = screen.getByRole("button", { name: "Copy" });

// Click rapidly multiple times
fireEvent.click(button);
fireEvent.click(button);
fireEvent.click(button);

await waitFor(() => {
// Should show success state after the last click
expect(button).toHaveTextContent("Copied!");
});

// After the duration, should revert once
await waitFor(
() => {
expect(button).toHaveTextContent("Copy");
},
{ timeout: 3000 }
);
});

it("uses i18n default labels when no explicit labels provided", async () => {
render(<CopyButton value="test-value" />);

const button = screen.getByRole("button", { name: "Copy" });
expect(button).toHaveTextContent("Copy");

fireEvent.click(button);

await waitFor(() => {
expect(button).toHaveTextContent("Copied!");
});
});

it("supports keyboard shortcut copy while focused", async () => {
render(<CopyButton value="tx-42" label="Copy" />);

Expand Down Expand Up @@ -64,4 +174,27 @@ describe("CopyButton", () => {
);
});
});

it("does not show check icon when copy fails", async () => {
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: {
writeText: vi.fn().mockRejectedValue(new Error("copy denied")),
},
});

Object.defineProperty(document, "execCommand", {
configurable: true,
value: vi.fn(() => false),
});

render(<CopyButton value="cannot-copy" label="Copy" />);

fireEvent.click(screen.getByRole("button", { name: "Copy" }));

await waitFor(() => {
const button = screen.getByRole("button", { name: "Copy" });
expect(button.querySelector("svg")).toBeFalsy();
});
});
});
33 changes: 26 additions & 7 deletions src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import type { KeyboardEvent, MouseEvent } from "react";
import { useTranslation } from "react-i18next";
import { CheckIcon } from "@heroicons/react/24/outline";
import useCopyToClipboard, {
type CopyFormat,
type CopyOptions,
Expand All @@ -22,9 +24,9 @@ interface CopyButtonProps {

export default function CopyButton({
value,
label = "Copy",
copiedLabel = "Copied",
failedLabel = "Failed",
label,
copiedLabel,
failedLabel,
className = "",
format = "text",
mimeType,
Expand All @@ -35,12 +37,17 @@ export default function CopyButton({
stopPropagation = true,
ariaLabel,
}: CopyButtonProps) {
const { t } = useTranslation();
const { copy, status, message } = useCopyToClipboard();

const resolvedLabel = label ?? t("copyButton.copy", "Copy");
const resolvedCopiedLabel = copiedLabel ?? t("copyButton.copied", "Copied!");
const resolvedFailedLabel = failedLabel ?? t("copyButton.failed", "Failed");

const buttonBaseClass =
variant === "inline"
? "text-xs font-medium text-stellar-blue hover:text-stellar-text-primary underline underline-offset-2 focus:outline-none focus:ring-2 focus:ring-stellar-blue rounded px-1 py-0.5"
: "inline-flex items-center justify-center min-h-9 px-3 py-1.5 text-xs font-medium rounded-md border border-stellar-border text-stellar-text-secondary hover:text-stellar-text-primary hover:border-stellar-blue focus:outline-none focus:ring-2 focus:ring-stellar-blue transition-colors";
: "inline-flex items-center justify-center gap-1.5 min-h-9 px-3 py-1.5 text-xs font-medium rounded-md border border-stellar-border text-stellar-text-secondary hover:text-stellar-text-primary hover:border-stellar-blue focus:outline-none focus:ring-2 focus:ring-stellar-blue transition-colors";

const handleCopy = async (event?: MouseEvent | KeyboardEvent) => {
if (stopPropagation && event) {
Expand Down Expand Up @@ -68,7 +75,14 @@ export default function CopyButton({
};

const visibleLabel =
status === "success" ? copiedLabel : status === "error" ? failedLabel : label;
status === "success"
? resolvedCopiedLabel
: status === "error"
? resolvedFailedLabel
: resolvedLabel;

const isSuccess = status === "success";
const buttonAriaLabel = ariaLabel ?? resolvedLabel;

return (
<span className="inline-flex items-center gap-2">
Expand All @@ -79,9 +93,14 @@ export default function CopyButton({
}}
onKeyDown={handleKeyDown}
className={`${buttonBaseClass} ${className}`.trim()}
aria-label={ariaLabel ?? label}
aria-label={isSuccess ? `${buttonAriaLabel} - ${resolvedCopiedLabel}` : buttonAriaLabel}
>
{visibleLabel}
{isSuccess && (
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-emerald-500" aria-hidden="true" />
)}
<span className={isSuccess ? "text-emerald-500" : ""}>
{visibleLabel}
</span>
</button>
<span className="sr-only" role="status" aria-live="polite">
{message}
Expand Down
1 change: 1 addition & 0 deletions src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const MockAuthProvider: React.FC<{ children: React.ReactNode; user?: User
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

// eslint-disable-next-line react-refresh/only-export-components
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (!ctx) {
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ interface CopyState {
message: string;
}

const DEFAULT_SUCCESS_DURATION_MS = 2000;
const DEFAULT_SUCCESS_DURATION_MS = 1500;

function toText(value: unknown, format: CopyFormat): string {
if (value === null || value === undefined) {
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@
"exactTitle": "آخر تحديث: {{timestamp}}",
"ariaLabel": "الاتصال: {{status}}. {{updated}}"
},
"copyButton": {
"copy": "نسخ",
"copied": "تم النسخ!",
"failed": "فشل"
},
"app": {
"loadingPage": "جارٍ تحميل الصفحة..."
}
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@
"exactTitle": "Zuletzt aktualisiert: {{timestamp}}",
"ariaLabel": "Verbindung: {{status}}. {{updated}}"
},
"copyButton": {
"copy": "Kopieren",
"copied": "Kopiert!",
"failed": "Fehlgeschlagen"
},
"app": {
"loadingPage": "Seite wird geladen..."
}
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@
"exactTitle": "Last updated: {{timestamp}}",
"ariaLabel": "Connection: {{status}}. {{updated}}"
},
"copyButton": {
"copy": "Copy",
"copied": "Copied!",
"failed": "Failed"
},
"app": {
"loadingPage": "Loading page..."
}
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@
"exactTitle": "Última actualización: {{timestamp}}",
"ariaLabel": "Conexión: {{status}}. {{updated}}"
},
"copyButton": {
"copy": "Copiar",
"copied": "¡Copiado!",
"failed": "Falló"
},
"app": {
"loadingPage": "Cargando página..."
}
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@
"exactTitle": "Dernière mise à jour : {{timestamp}}",
"ariaLabel": "Connexion : {{status}}. {{updated}}"
},
"copyButton": {
"copy": "Copier",
"copied": "Copié !",
"failed": "Échec"
},
"app": {
"loadingPage": "Chargement de la page..."
}
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@
"exactTitle": "最終更新: {{timestamp}}",
"ariaLabel": "接続: {{status}}。{{updated}}"
},
"copyButton": {
"copy": "コピー",
"copied": "コピーしました!",
"failed": "失敗"
},
"app": {
"loadingPage": "ページを読み込み中..."
}
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@
"exactTitle": "마지막 업데이트: {{timestamp}}",
"ariaLabel": "연결: {{status}}. {{updated}}"
},
"copyButton": {
"copy": "복사",
"copied": "복사됨!",
"failed": "실패"
},
"app": {
"loadingPage": "페이지 로딩 중..."
}
Expand Down
5 changes: 5 additions & 0 deletions src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,11 @@
"exactTitle": "最后更新:{{timestamp}}",
"ariaLabel": "连接:{{status}}。{{updated}}"
},
"copyButton": {
"copy": "复制",
"copied": "已复制!",
"failed": "失败"
},
"app": {
"loadingPage": "正在加载页面..."
}
Expand Down
Loading