diff --git a/src/components/AddressDisplay.test.tsx b/src/components/AddressDisplay.test.tsx
index 76899cc..a6aa7c9 100644
--- a/src/components/AddressDisplay.test.tsx
+++ b/src/components/AddressDisplay.test.tsx
@@ -28,7 +28,7 @@ describe("AddressDisplay", () => {
expect(copyBtn).not.toHaveAttribute("tabindex", "-1");
await act(async () => { fireEvent.click(copyBtn); });
expect(mockWriteText).toHaveBeenCalledWith(address);
- expect(screen.getByRole("button", { name: "Address copied" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByRole("button", { name: "Copy address" })).toBeInTheDocument();
}, { timeout: 2500 });
@@ -42,6 +42,65 @@ describe("AddressDisplay", () => {
});
});
+ describe("copy operation & clipboard fallback (#604)", () => {
+ it("resets aria-label back to 'Copy address' after 2 seconds using fake timers", async () => {
+ vi.useFakeTimers();
+ render();
+ const copyBtn = screen.getByRole("button", { name: "Copy address" });
+
+ await act(async () => {
+ fireEvent.click(copyBtn);
+ });
+
+ expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(2000);
+ });
+
+ expect(screen.getByRole("button", { name: "Copy address" })).toBeInTheDocument();
+ vi.useRealTimers();
+ });
+
+ it("triggers document.execCommand fallback when navigator.clipboard.writeText rejects", async () => {
+ mockWriteText.mockRejectedValueOnce(new Error("Clipboard access denied"));
+ const execCommandSpy = vi.fn();
+ const originalExecCommand = document.execCommand;
+ document.execCommand = execCommandSpy;
+
+ render();
+ const copyBtn = screen.getByRole("button", { name: "Copy address" });
+
+ await act(async () => {
+ fireEvent.click(copyBtn);
+ });
+
+ expect(execCommandSpy).toHaveBeenCalledWith("copy");
+ expect(screen.getByRole("button", { name: "Copied!" })).toBeInTheDocument();
+
+ document.execCommand = originalExecCommand;
+ });
+
+ it("does not throw an error when clipboard is unavailable and fallback fails", async () => {
+ mockWriteText.mockRejectedValueOnce(new Error("Clipboard access denied"));
+ const originalExecCommand = document.execCommand;
+ document.execCommand = vi.fn().mockImplementation(() => {
+ throw new Error("execCommand unsupported");
+ });
+
+ render();
+ const copyBtn = screen.getByRole("button", { name: "Copy address" });
+
+ await expect(
+ act(async () => {
+ fireEvent.click(copyBtn);
+ })
+ ).resolves.not.toThrow();
+
+ document.execCommand = originalExecCommand;
+ });
+ });
+
describe("showFull prop", () => {
it("renders the full address and applies select-all class", () => {
render();
diff --git a/src/components/AddressDisplay.tsx b/src/components/AddressDisplay.tsx
index 4bb494b..9c62f70 100644
--- a/src/components/AddressDisplay.tsx
+++ b/src/components/AddressDisplay.tsx
@@ -45,7 +45,17 @@ export function AddressDisplay({
onCopy?.();
setTimeout(() => setCopied(false), 2000);
} catch {
- /* fallback */
+ /* fallback for non-HTTPS or rejected writeText */
+ try {
+ if (typeof document !== "undefined" && typeof document.execCommand === "function") {
+ document.execCommand("copy");
+ }
+ setCopied(true);
+ onCopy?.();
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ /* ignore */
+ }
}
}
@@ -72,7 +82,7 @@ export function AddressDisplay({