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 (