From 33d716edfbdf6785e3ed487c6343d03331d66c73 Mon Sep 17 00:00:00 2001 From: Victor Nwokenekwu <69258253+vrickish@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:30:04 +0000 Subject: [PATCH 1/3] feat: add Copied! confirmation with check icon and i18n to CopyButton --- src/components/CopyButton.test.tsx | 137 ++++++++++++++++++++++++++++- src/components/CopyButton.tsx | 33 +++++-- src/hooks/useCopyToClipboard.ts | 2 +- src/i18n/locales/ar.json | 5 ++ src/i18n/locales/de.json | 5 ++ src/i18n/locales/en.json | 5 ++ src/i18n/locales/es.json | 5 ++ src/i18n/locales/fr.json | 5 ++ src/i18n/locales/ja.json | 5 ++ src/i18n/locales/ko.json | 5 ++ src/i18n/locales/zh.json | 5 ++ 11 files changed, 202 insertions(+), 10 deletions(-) diff --git a/src/components/CopyButton.test.tsx b/src/components/CopyButton.test.tsx index ea059d9..870f25e 100644 --- a/src/components/CopyButton.test.tsx +++ b/src/components/CopyButton.test.tsx @@ -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", () => { @@ -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(); + + 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(); + + 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(); + + 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( + + ); + + 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( + + ); + + 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(); + + 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(); @@ -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(); + + fireEvent.click(screen.getByRole("button", { name: "Copy" })); + + await waitFor(() => { + const button = screen.getByRole("button", { name: "Copy" }); + expect(button.querySelector("svg")).toBeFalsy(); + }); + }); }); diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx index 3d7bd88..929af9e 100644 --- a/src/components/CopyButton.tsx +++ b/src/components/CopyButton.tsx @@ -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, @@ -22,9 +24,9 @@ interface CopyButtonProps { export default function CopyButton({ value, - label = "Copy", - copiedLabel = "Copied", - failedLabel = "Failed", + label, + copiedLabel, + failedLabel, className = "", format = "text", mimeType, @@ -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) { @@ -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 ( @@ -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 && ( +