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
252 changes: 252 additions & 0 deletions src/lib/components/buttonv2/CopyButton.component.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CopyButton textToCopy="test" />, { 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(<CopyButton textToCopy="test" />, { 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(<CopyButton textToCopy="hello" />, { 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(<CopyButton textToCopy="<b>hi</b>" copyAsHtml />, {
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(<CopyButton textToCopy="<b>hi</b>" copyAsHtml />, {
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(<CopyButton textToCopy="x" />, { 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(<CopyButton textToCopy="x" label="key" />, { 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(<CopyButton textToCopy="x" variant="outline" />, {
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(<CopyButton variant="outline" textToCopy="x" label="key" />, {
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();
});
});
});
49 changes: 28 additions & 21 deletions src/lib/components/buttonv2/CopyButton.component.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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';
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);
Expand Down Expand Up @@ -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);
});
}
};

Expand All @@ -65,45 +72,45 @@ export const CopyButton = ({
variant?: 'outline' | 'ghost';
} & Omit<Props, 'tooltip' | 'label'>) => {
const { copy, copyStatus } = useClipboard();
const isSuccess = copyStatus === COPY_STATE_SUCCESS;
return (
<Button
{...props}
variant={variant === 'outline' ? 'outline' : undefined}
style={{
...props.style,
...(isSuccess && { cursor: 'not-allowed', opacity: 0.5 }),
minWidth:
//Just to make sure the width of the button stays the same when copied!
variant === 'outline'
? (label ? label.length / 2 : 0) + 7 + 'rem'
? `${(label ? label.length / 2 : 0) + 7}rem`
: undefined,
}}
label={
variant === 'outline'
? copyStatus === COPY_STATE_SUCCESS
? `Copied${label ? ' ' + label + '' : ''}!`
: `Copy${label ? ' ' + label : ''}`
? isSuccess
? `Copied${label ? ` ${label}` : ''}!`
: `Copy${label ? ` ${label}` : ''}`
: undefined
}
icon={
<Icon
name={copyStatus === COPY_STATE_SUCCESS ? 'Check' : 'Copy'}
color={
copyStatus === COPY_STATE_SUCCESS ? 'statusHealthy' : undefined
}
name={isSuccess ? 'Check' : 'Copy'}
color={isSuccess ? 'statusHealthy' : undefined}
/>
}
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
Expand Down
Loading