diff --git a/src/lib/components/buttonv2/CopyButton.component.test.tsx b/src/lib/components/buttonv2/CopyButton.component.test.tsx
new file mode 100644
index 0000000000..c53fbbf1d4
--- /dev/null
+++ b/src/lib/components/buttonv2/CopyButton.component.test.tsx
@@ -0,0 +1,252 @@
+import "@testing-library/jest-dom";
+import { act, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import React from "react";
+import { getWrapper } from "../../testUtils";
+import { CopyButton } from "./CopyButton.component";
+
+describe("CopyButton", () => {
+ const { Wrapper } = getWrapper();
+ const originalClipboard = navigator.clipboard;
+
+ beforeEach(() => {
+ Object.assign(navigator, {
+ clipboard: {
+ writeText: jest.fn(),
+ write: jest.fn(),
+ },
+ });
+ // JSDOM doesn't define ClipboardItem; needed for copyAsHtml path. Stub only — full interface not needed for tests.
+ if (typeof globalThis.ClipboardItem === "undefined") {
+ // @ts-expect-error — intentional stub for JSDOM; global ClipboardItem type expects full interface (getType, types, etc.)
+ globalThis.ClipboardItem = class {};
+ }
+ });
+
+ afterEach(() => {
+ Object.assign(navigator, { clipboard: originalClipboard });
+ jest.restoreAllMocks();
+ });
+
+ it("when writeText returns a rejected Promise, should enter the unsupported state", async () => {
+ const writeTextMock = navigator.clipboard.writeText as jest.Mock;
+ writeTextMock.mockRejectedValueOnce(new Error("Clipboard denied"));
+
+ render(, { wrapper: Wrapper });
+
+ const button = screen.getByRole("button", { name: "Copy" });
+ await act(() => userEvent.click(button));
+
+ await waitFor(() => {
+ expect(writeTextMock).toHaveBeenCalledWith("test");
+ });
+
+ // Unsupported state: button should not show success, and not be aria-disabled
+ expect(
+ screen.queryByRole("button", { name: "Copied !" }),
+ ).not.toBeInTheDocument();
+ const buttonAfterReject = screen.getByRole("button", { name: "Copy" });
+ expect(buttonAfterReject).not.toHaveAttribute("aria-disabled", "true");
+ });
+
+ it("during success state click does nothing; after 2s resets to idle and click works again", async () => {
+ jest.useFakeTimers();
+ try {
+ const writeTextMock = navigator.clipboard.writeText as jest.Mock;
+ writeTextMock.mockResolvedValue(undefined);
+
+ render(, { wrapper: Wrapper });
+
+ await act(() =>
+ userEvent.click(screen.getByRole("button", { name: "Copy" })),
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: "Copied !" }),
+ ).toBeInTheDocument();
+ });
+
+ expect(writeTextMock).toHaveBeenCalledTimes(1);
+ const inSuccess = screen.getByRole("button", { name: "Copied !" });
+ expect(inSuccess).toHaveAttribute("aria-disabled", "true");
+
+ await act(() => userEvent.click(inSuccess));
+ expect(writeTextMock).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ jest.advanceTimersByTime(2000);
+ });
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: "Copy" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Copy" }),
+ ).not.toHaveAttribute("aria-disabled", "true");
+ });
+
+ await act(() =>
+ userEvent.click(screen.getByRole("button", { name: "Copy" })),
+ );
+
+ await waitFor(() => {
+ expect(writeTextMock).toHaveBeenCalledTimes(2);
+ });
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it("standard copy (writeText): on success shows Copied ! tooltip and aria-disabled", async () => {
+ const writeTextMock = navigator.clipboard.writeText as jest.Mock;
+ writeTextMock.mockResolvedValueOnce(undefined);
+
+ render(, { wrapper: Wrapper });
+
+ await act(() =>
+ userEvent.click(screen.getByRole("button", { name: "Copy" })),
+ );
+
+ await waitFor(() => {
+ expect(writeTextMock).toHaveBeenCalledWith("hello");
+ const button = screen.getByRole("button", { name: "Copied !" });
+ expect(button).toHaveAttribute("aria-disabled", "true");
+ });
+ });
+
+ it("copyAsHtml: calls clipboard.write and on success shows Copied ! and aria-disabled", async () => {
+ const writeMock = navigator.clipboard.write as jest.Mock;
+ writeMock.mockResolvedValueOnce(undefined);
+
+ render(, {
+ wrapper: Wrapper,
+ });
+
+ await act(() =>
+ userEvent.click(screen.getByRole("button", { name: "Copy" })),
+ );
+
+ await waitFor(() => {
+ expect(writeMock).toHaveBeenCalledTimes(1);
+ expect(writeMock.mock.calls[0][0]).toHaveLength(1);
+ expect(
+ screen.getByRole("button", { name: "Copied !" }),
+ ).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Copied !" })).toHaveAttribute(
+ "aria-disabled",
+ "true",
+ );
+ });
+ });
+
+ it("copyAsHtml: when write rejects, enters unsupported state", async () => {
+ const writeMock = navigator.clipboard.write as jest.Mock;
+ writeMock.mockRejectedValueOnce(new Error("Denied"));
+
+ render(, {
+ wrapper: Wrapper,
+ });
+
+ await act(() =>
+ userEvent.click(screen.getByRole("button", { name: "Copy" })),
+ );
+
+ await waitFor(() => {
+ expect(writeMock).toHaveBeenCalled();
+ });
+
+ expect(
+ screen.queryByRole("button", { name: "Copied !" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Copy" })).not.toHaveAttribute(
+ "aria-disabled",
+ "true",
+ );
+ });
+
+ it("ghost variant without label: tooltip is Copy then Copied ! on success", async () => {
+ const writeTextMock = navigator.clipboard.writeText as jest.Mock;
+ writeTextMock.mockResolvedValueOnce(undefined);
+
+ render(, { wrapper: Wrapper });
+
+ expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
+
+ await act(() =>
+ userEvent.click(screen.getByRole("button", { name: "Copy" })),
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: "Copied !" }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("ghost variant with label: tooltip is Copy {label} then Copied ! on success", async () => {
+ const writeTextMock = navigator.clipboard.writeText as jest.Mock;
+ writeTextMock.mockResolvedValueOnce(undefined);
+
+ render(, { wrapper: Wrapper });
+
+ expect(
+ screen.getByRole("button", { name: "Copy key" }),
+ ).toBeInTheDocument();
+
+ await act(() =>
+ userEvent.click(screen.getByRole("button", { name: "Copy key" })),
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: "Copied !" }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("outline variant without label: tooltip is Copy then Copied! on success", async () => {
+ const writeTextMock = navigator.clipboard.writeText as jest.Mock;
+ writeTextMock.mockResolvedValueOnce(undefined);
+
+ render(, {
+ wrapper: Wrapper,
+ });
+
+ expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
+
+ await act(() =>
+ userEvent.click(screen.getByRole("button", { name: "Copy" })),
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: "Copied!" }),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it("outline variant with label: tooltip is Copy {label} then Copied {label}! on success", async () => {
+ const writeTextMock = navigator.clipboard.writeText as jest.Mock;
+ writeTextMock.mockResolvedValueOnce(undefined);
+
+ render(, {
+ wrapper: Wrapper,
+ });
+
+ expect(
+ screen.getByRole("button", { name: "Copy key" }),
+ ).toBeInTheDocument();
+
+ await act(() =>
+ userEvent.click(screen.getByRole("button", { name: "Copy key" })),
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByRole("button", { name: "Copied key!" }),
+ ).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/lib/components/buttonv2/CopyButton.component.tsx b/src/lib/components/buttonv2/CopyButton.component.tsx
index 3f3074929a..787f197ca4 100644
--- a/src/lib/components/buttonv2/CopyButton.component.tsx
+++ b/src/lib/components/buttonv2/CopyButton.component.tsx
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Icon } from '../icon/Icon.component';
-import { Button, Props } from './Buttonv2.component';
+import { Button, type Props } from './Buttonv2.component';
export const COPY_STATE_IDLE = 'idle';
export const COPY_STATE_SUCCESS = 'success';
@@ -8,6 +8,7 @@ export const COPY_STATE_UNSUPPORTED = 'unsupported';
export const useClipboard = () => {
const [copyStatus, setCopyStatus] = useState(COPY_STATE_IDLE);
useEffect(() => {
+ if (copyStatus === COPY_STATE_IDLE) return;
const timer = setTimeout(() => {
setCopyStatus(COPY_STATE_IDLE);
}, 2000);
@@ -41,8 +42,14 @@ export const useClipboard = () => {
});
} else {
// Copy as plain text only
- navigator.clipboard.writeText(text);
- setCopyStatus(COPY_STATE_SUCCESS);
+ navigator.clipboard
+ .writeText(text)
+ .then(() => {
+ setCopyStatus(COPY_STATE_SUCCESS);
+ })
+ .catch(() => {
+ setCopyStatus(COPY_STATE_UNSUPPORTED);
+ });
}
};
@@ -65,45 +72,45 @@ export const CopyButton = ({
variant?: 'outline' | 'ghost';
} & Omit) => {
const { copy, copyStatus } = useClipboard();
+ const isSuccess = copyStatus === COPY_STATE_SUCCESS;
return (
}
- disabled={copyStatus === COPY_STATE_SUCCESS || props.disabled}
- onClick={() => copy(textToCopy, copyAsHtml)}
+ disabled={props.disabled}
+ aria-disabled={isSuccess || props.disabled}
+ onClick={() => {
+ if (!isSuccess) copy(textToCopy, copyAsHtml);
+ }}
type="button"
tooltip={
variant !== 'outline'
? {
- overlay:
- copyStatus === COPY_STATE_SUCCESS
- ? 'Copied !'
- : `Copy${label ? ' ' + label : ''}`,
- overlayStyle: {
- maxWidth: '20rem',
- },
+ overlay: isSuccess
+ ? 'Copied !'
+ : `Copy${label ? ` ${label}` : ''}`,
+ overlayStyle: { maxWidth: '20rem' },
placement: 'top',
}
: undefined