Skip to content

Commit 5d8059c

Browse files
committed
Merge branch 'bugfix/CUI-6-copied-tooltip-FF' into q/1.0
2 parents 27eaac5 + e59db08 commit 5d8059c

File tree

2 files changed

+280
-21
lines changed

2 files changed

+280
-21
lines changed
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import "@testing-library/jest-dom";
2+
import { act, render, screen, waitFor } from "@testing-library/react";
3+
import userEvent from "@testing-library/user-event";
4+
import React from "react";
5+
import { getWrapper } from "../../testUtils";
6+
import { CopyButton } from "./CopyButton.component";
7+
8+
describe("CopyButton", () => {
9+
const { Wrapper } = getWrapper();
10+
const originalClipboard = navigator.clipboard;
11+
12+
beforeEach(() => {
13+
Object.assign(navigator, {
14+
clipboard: {
15+
writeText: jest.fn(),
16+
write: jest.fn(),
17+
},
18+
});
19+
// JSDOM doesn't define ClipboardItem; needed for copyAsHtml path. Stub only — full interface not needed for tests.
20+
if (typeof globalThis.ClipboardItem === "undefined") {
21+
// @ts-expect-error — intentional stub for JSDOM; global ClipboardItem type expects full interface (getType, types, etc.)
22+
globalThis.ClipboardItem = class {};
23+
}
24+
});
25+
26+
afterEach(() => {
27+
Object.assign(navigator, { clipboard: originalClipboard });
28+
jest.restoreAllMocks();
29+
});
30+
31+
it("when writeText returns a rejected Promise, should enter the unsupported state", async () => {
32+
const writeTextMock = navigator.clipboard.writeText as jest.Mock;
33+
writeTextMock.mockRejectedValueOnce(new Error("Clipboard denied"));
34+
35+
render(<CopyButton textToCopy="test" />, { wrapper: Wrapper });
36+
37+
const button = screen.getByRole("button", { name: "Copy" });
38+
await act(() => userEvent.click(button));
39+
40+
await waitFor(() => {
41+
expect(writeTextMock).toHaveBeenCalledWith("test");
42+
});
43+
44+
// Unsupported state: button should not show success, and not be aria-disabled
45+
expect(
46+
screen.queryByRole("button", { name: "Copied !" }),
47+
).not.toBeInTheDocument();
48+
const buttonAfterReject = screen.getByRole("button", { name: "Copy" });
49+
expect(buttonAfterReject).not.toHaveAttribute("aria-disabled", "true");
50+
});
51+
52+
it("during success state click does nothing; after 2s resets to idle and click works again", async () => {
53+
jest.useFakeTimers();
54+
try {
55+
const writeTextMock = navigator.clipboard.writeText as jest.Mock;
56+
writeTextMock.mockResolvedValue(undefined);
57+
58+
render(<CopyButton textToCopy="test" />, { wrapper: Wrapper });
59+
60+
await act(() =>
61+
userEvent.click(screen.getByRole("button", { name: "Copy" })),
62+
);
63+
64+
await waitFor(() => {
65+
expect(
66+
screen.getByRole("button", { name: "Copied !" }),
67+
).toBeInTheDocument();
68+
});
69+
70+
expect(writeTextMock).toHaveBeenCalledTimes(1);
71+
const inSuccess = screen.getByRole("button", { name: "Copied !" });
72+
expect(inSuccess).toHaveAttribute("aria-disabled", "true");
73+
74+
await act(() => userEvent.click(inSuccess));
75+
expect(writeTextMock).toHaveBeenCalledTimes(1);
76+
77+
act(() => {
78+
jest.advanceTimersByTime(2000);
79+
});
80+
81+
await waitFor(() => {
82+
expect(
83+
screen.getByRole("button", { name: "Copy" }),
84+
).toBeInTheDocument();
85+
expect(
86+
screen.getByRole("button", { name: "Copy" }),
87+
).not.toHaveAttribute("aria-disabled", "true");
88+
});
89+
90+
await act(() =>
91+
userEvent.click(screen.getByRole("button", { name: "Copy" })),
92+
);
93+
94+
await waitFor(() => {
95+
expect(writeTextMock).toHaveBeenCalledTimes(2);
96+
});
97+
} finally {
98+
jest.useRealTimers();
99+
}
100+
});
101+
102+
it("standard copy (writeText): on success shows Copied ! tooltip and aria-disabled", async () => {
103+
const writeTextMock = navigator.clipboard.writeText as jest.Mock;
104+
writeTextMock.mockResolvedValueOnce(undefined);
105+
106+
render(<CopyButton textToCopy="hello" />, { wrapper: Wrapper });
107+
108+
await act(() =>
109+
userEvent.click(screen.getByRole("button", { name: "Copy" })),
110+
);
111+
112+
await waitFor(() => {
113+
expect(writeTextMock).toHaveBeenCalledWith("hello");
114+
const button = screen.getByRole("button", { name: "Copied !" });
115+
expect(button).toHaveAttribute("aria-disabled", "true");
116+
});
117+
});
118+
119+
it("copyAsHtml: calls clipboard.write and on success shows Copied ! and aria-disabled", async () => {
120+
const writeMock = navigator.clipboard.write as jest.Mock;
121+
writeMock.mockResolvedValueOnce(undefined);
122+
123+
render(<CopyButton textToCopy="<b>hi</b>" copyAsHtml />, {
124+
wrapper: Wrapper,
125+
});
126+
127+
await act(() =>
128+
userEvent.click(screen.getByRole("button", { name: "Copy" })),
129+
);
130+
131+
await waitFor(() => {
132+
expect(writeMock).toHaveBeenCalledTimes(1);
133+
expect(writeMock.mock.calls[0][0]).toHaveLength(1);
134+
expect(
135+
screen.getByRole("button", { name: "Copied !" }),
136+
).toBeInTheDocument();
137+
expect(screen.getByRole("button", { name: "Copied !" })).toHaveAttribute(
138+
"aria-disabled",
139+
"true",
140+
);
141+
});
142+
});
143+
144+
it("copyAsHtml: when write rejects, enters unsupported state", async () => {
145+
const writeMock = navigator.clipboard.write as jest.Mock;
146+
writeMock.mockRejectedValueOnce(new Error("Denied"));
147+
148+
render(<CopyButton textToCopy="<b>hi</b>" copyAsHtml />, {
149+
wrapper: Wrapper,
150+
});
151+
152+
await act(() =>
153+
userEvent.click(screen.getByRole("button", { name: "Copy" })),
154+
);
155+
156+
await waitFor(() => {
157+
expect(writeMock).toHaveBeenCalled();
158+
});
159+
160+
expect(
161+
screen.queryByRole("button", { name: "Copied !" }),
162+
).not.toBeInTheDocument();
163+
expect(screen.getByRole("button", { name: "Copy" })).not.toHaveAttribute(
164+
"aria-disabled",
165+
"true",
166+
);
167+
});
168+
169+
it("ghost variant without label: tooltip is Copy then Copied ! on success", async () => {
170+
const writeTextMock = navigator.clipboard.writeText as jest.Mock;
171+
writeTextMock.mockResolvedValueOnce(undefined);
172+
173+
render(<CopyButton textToCopy="x" />, { wrapper: Wrapper });
174+
175+
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
176+
177+
await act(() =>
178+
userEvent.click(screen.getByRole("button", { name: "Copy" })),
179+
);
180+
181+
await waitFor(() => {
182+
expect(
183+
screen.getByRole("button", { name: "Copied !" }),
184+
).toBeInTheDocument();
185+
});
186+
});
187+
188+
it("ghost variant with label: tooltip is Copy {label} then Copied ! on success", async () => {
189+
const writeTextMock = navigator.clipboard.writeText as jest.Mock;
190+
writeTextMock.mockResolvedValueOnce(undefined);
191+
192+
render(<CopyButton textToCopy="x" label="key" />, { wrapper: Wrapper });
193+
194+
expect(
195+
screen.getByRole("button", { name: "Copy key" }),
196+
).toBeInTheDocument();
197+
198+
await act(() =>
199+
userEvent.click(screen.getByRole("button", { name: "Copy key" })),
200+
);
201+
202+
await waitFor(() => {
203+
expect(
204+
screen.getByRole("button", { name: "Copied !" }),
205+
).toBeInTheDocument();
206+
});
207+
});
208+
209+
it("outline variant without label: tooltip is Copy then Copied! on success", async () => {
210+
const writeTextMock = navigator.clipboard.writeText as jest.Mock;
211+
writeTextMock.mockResolvedValueOnce(undefined);
212+
213+
render(<CopyButton textToCopy="x" variant="outline" />, {
214+
wrapper: Wrapper,
215+
});
216+
217+
expect(screen.getByRole("button", { name: "Copy" })).toBeInTheDocument();
218+
219+
await act(() =>
220+
userEvent.click(screen.getByRole("button", { name: "Copy" })),
221+
);
222+
223+
await waitFor(() => {
224+
expect(
225+
screen.getByRole("button", { name: "Copied!" }),
226+
).toBeInTheDocument();
227+
});
228+
});
229+
230+
it("outline variant with label: tooltip is Copy {label} then Copied {label}! on success", async () => {
231+
const writeTextMock = navigator.clipboard.writeText as jest.Mock;
232+
writeTextMock.mockResolvedValueOnce(undefined);
233+
234+
render(<CopyButton variant="outline" textToCopy="x" label="key" />, {
235+
wrapper: Wrapper,
236+
});
237+
238+
expect(
239+
screen.getByRole("button", { name: "Copy key" }),
240+
).toBeInTheDocument();
241+
242+
await act(() =>
243+
userEvent.click(screen.getByRole("button", { name: "Copy key" })),
244+
);
245+
246+
await waitFor(() => {
247+
expect(
248+
screen.getByRole("button", { name: "Copied key!" }),
249+
).toBeInTheDocument();
250+
});
251+
});
252+
});

src/lib/components/buttonv2/CopyButton.component.tsx

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import { useEffect, useState } from 'react';
22
import { Icon } from '../icon/Icon.component';
3-
import { Button, Props } from './Buttonv2.component';
3+
import { Button, type Props } from './Buttonv2.component';
44

55
export const COPY_STATE_IDLE = 'idle';
66
export const COPY_STATE_SUCCESS = 'success';
77
export const COPY_STATE_UNSUPPORTED = 'unsupported';
88
export const useClipboard = () => {
99
const [copyStatus, setCopyStatus] = useState(COPY_STATE_IDLE);
1010
useEffect(() => {
11+
if (copyStatus === COPY_STATE_IDLE) return;
1112
const timer = setTimeout(() => {
1213
setCopyStatus(COPY_STATE_IDLE);
1314
}, 2000);
@@ -41,8 +42,14 @@ export const useClipboard = () => {
4142
});
4243
} else {
4344
// Copy as plain text only
44-
navigator.clipboard.writeText(text);
45-
setCopyStatus(COPY_STATE_SUCCESS);
45+
navigator.clipboard
46+
.writeText(text)
47+
.then(() => {
48+
setCopyStatus(COPY_STATE_SUCCESS);
49+
})
50+
.catch(() => {
51+
setCopyStatus(COPY_STATE_UNSUPPORTED);
52+
});
4653
}
4754
};
4855

@@ -65,45 +72,45 @@ export const CopyButton = ({
6572
variant?: 'outline' | 'ghost';
6673
} & Omit<Props, 'tooltip' | 'label'>) => {
6774
const { copy, copyStatus } = useClipboard();
75+
const isSuccess = copyStatus === COPY_STATE_SUCCESS;
6876
return (
6977
<Button
7078
{...props}
7179
variant={variant === 'outline' ? 'outline' : undefined}
7280
style={{
81+
...props.style,
82+
...(isSuccess && { cursor: 'not-allowed', opacity: 0.5 }),
7383
minWidth:
74-
//Just to make sure the width of the button stays the same when copied!
7584
variant === 'outline'
76-
? (label ? label.length / 2 : 0) + 7 + 'rem'
85+
? `${(label ? label.length / 2 : 0) + 7}rem`
7786
: undefined,
7887
}}
7988
label={
8089
variant === 'outline'
81-
? copyStatus === COPY_STATE_SUCCESS
82-
? `Copied${label ? ' ' + label + '' : ''}!`
83-
: `Copy${label ? ' ' + label : ''}`
90+
? isSuccess
91+
? `Copied${label ? ` ${label}` : ''}!`
92+
: `Copy${label ? ` ${label}` : ''}`
8493
: undefined
8594
}
8695
icon={
8796
<Icon
88-
name={copyStatus === COPY_STATE_SUCCESS ? 'Check' : 'Copy'}
89-
color={
90-
copyStatus === COPY_STATE_SUCCESS ? 'statusHealthy' : undefined
91-
}
97+
name={isSuccess ? 'Check' : 'Copy'}
98+
color={isSuccess ? 'statusHealthy' : undefined}
9299
/>
93100
}
94-
disabled={copyStatus === COPY_STATE_SUCCESS || props.disabled}
95-
onClick={() => copy(textToCopy, copyAsHtml)}
101+
disabled={props.disabled}
102+
aria-disabled={isSuccess || props.disabled}
103+
onClick={() => {
104+
if (!isSuccess) copy(textToCopy, copyAsHtml);
105+
}}
96106
type="button"
97107
tooltip={
98108
variant !== 'outline'
99109
? {
100-
overlay:
101-
copyStatus === COPY_STATE_SUCCESS
102-
? 'Copied !'
103-
: `Copy${label ? ' ' + label : ''}`,
104-
overlayStyle: {
105-
maxWidth: '20rem',
106-
},
110+
overlay: isSuccess
111+
? 'Copied !'
112+
: `Copy${label ? ` ${label}` : ''}`,
113+
overlayStyle: { maxWidth: '20rem' },
107114
placement: 'top',
108115
}
109116
: undefined

0 commit comments

Comments
 (0)