Skip to content

Commit dae9cdd

Browse files
authored
fix(copy-feedback): reset timeout after repeated copies (#823)
1 parent 2f9b17f commit dae9cdd

11 files changed

Lines changed: 291 additions & 101 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/kumo": patch
3+
---
4+
5+
Keep transient copy feedback visible until the final repeated copy click settles across Kumo copy controls.

‎packages/kumo-docs-astro/src/components/demos/CloudflareLogoDemo.tsx‎

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from "react";
1+
import { useEffect, useRef, useState } from "react";
22
import {
33
CloudflareLogo,
44
PoweredByCloudflare,
@@ -12,6 +12,8 @@ import {
1212
ArrowSquareOutIcon,
1313
} from "@phosphor-icons/react";
1414

15+
const COPIED_FEEDBACK_MS = 2000;
16+
1517
export function CloudflareLogoBasicDemo() {
1618
return <CloudflareLogo className="w-72" />;
1719
}
@@ -60,11 +62,42 @@ export function CloudflareLogoSizesDemo() {
6062

6163
export function CloudflareLogoCopyDemo() {
6264
const [copied, setCopied] = useState<string | null>(null);
65+
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
66+
const copyAttemptRef = useRef(0);
67+
68+
useEffect(() => {
69+
return () => {
70+
copyAttemptRef.current += 1;
71+
if (resetTimeoutRef.current !== null) {
72+
clearTimeout(resetTimeoutRef.current);
73+
resetTimeoutRef.current = null;
74+
}
75+
};
76+
}, []);
6377

6478
const copyToClipboard = async (text: string, label: string) => {
65-
await navigator.clipboard.writeText(text);
66-
setCopied(label);
67-
setTimeout(() => setCopied(null), 2000);
79+
const attempt = ++copyAttemptRef.current;
80+
if (resetTimeoutRef.current !== null) {
81+
clearTimeout(resetTimeoutRef.current);
82+
resetTimeoutRef.current = null;
83+
}
84+
85+
try {
86+
await navigator.clipboard.writeText(text);
87+
if (copyAttemptRef.current !== attempt) return;
88+
89+
setCopied(label);
90+
resetTimeoutRef.current = setTimeout(() => {
91+
if (copyAttemptRef.current !== attempt) return;
92+
setCopied(null);
93+
resetTimeoutRef.current = null;
94+
}, COPIED_FEEDBACK_MS);
95+
} catch (error) {
96+
if (copyAttemptRef.current !== attempt) return;
97+
98+
setCopied(null);
99+
console.error("Failed to copy logo SVG:", error);
100+
}
68101
};
69102

70103
return (

‎packages/kumo-docs-astro/src/components/docs/CopyPageButton.tsx‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState } from "react";
1+
import { useEffect, useRef, useState } from "react";
22
import { Button, DropdownMenu } from "@cloudflare/kumo";
33
import {
44
CopySimpleIcon,
@@ -15,8 +15,22 @@ interface CopyPageButtonProps {
1515
align?: "start" | "center" | "end";
1616
}
1717

18+
const COPIED_FEEDBACK_MS = 2000;
19+
1820
export function CopyPageButton({ align = "end" }: CopyPageButtonProps) {
1921
const [copied, setCopied] = useState(false);
22+
const resetTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
23+
const copyAttemptRef = useRef(0);
24+
25+
useEffect(() => {
26+
return () => {
27+
copyAttemptRef.current += 1;
28+
if (resetTimeoutRef.current !== null) {
29+
clearTimeout(resetTimeoutRef.current);
30+
resetTimeoutRef.current = null;
31+
}
32+
};
33+
}, []);
2034

2135
const getMarkdownUrl = () => {
2236
const url = new URL(window.location.href);
@@ -29,23 +43,37 @@ export function CopyPageButton({ align = "end" }: CopyPageButtonProps) {
2943
return `${url.origin}${path}.md`;
3044
};
3145

32-
const onCopySuccess = () => {
33-
setCopied(true);
46+
const onCopySuccess = (attempt: number) => {
47+
if (copyAttemptRef.current !== attempt) return;
3448

35-
setTimeout(() => setCopied(false), 2000); // 2 seconds
49+
setCopied(true);
50+
resetTimeoutRef.current = setTimeout(() => {
51+
if (copyAttemptRef.current !== attempt) return;
52+
setCopied(false);
53+
resetTimeoutRef.current = null;
54+
}, COPIED_FEEDBACK_MS);
3655
};
3756

3857
const handleCopyMarkdown = async () => {
39-
onCopySuccess();
58+
const attempt = ++copyAttemptRef.current;
59+
if (resetTimeoutRef.current !== null) {
60+
clearTimeout(resetTimeoutRef.current);
61+
resetTimeoutRef.current = null;
62+
}
4063

4164
try {
4265
const markdownUrl = getMarkdownUrl();
4366
const response = await fetch(markdownUrl);
4467
if (!response.ok) throw new Error(`HTTP ${response.status}`);
4568
const markdown = await response.text();
69+
if (copyAttemptRef.current !== attempt) return;
4670

4771
await navigator.clipboard.writeText(markdown);
72+
onCopySuccess(attempt);
4873
} catch (error) {
74+
if (copyAttemptRef.current !== attempt) return;
75+
76+
setCopied(false);
4977
console.error("Failed to copy page as Markdown:", error);
5078
}
5179
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { render, screen } from "@testing-library/react";
2+
import { describe, expect, it } from "vite-plus/test";
3+
import { DeleteResource } from "./delete-resource";
4+
5+
describe("DeleteResource", () => {
6+
it("renders a copy control for the resource name", () => {
7+
render(
8+
<DeleteResource
9+
open
10+
onOpenChange={() => {}}
11+
resourceType="Worker"
12+
resourceName="my-worker"
13+
onDelete={() => {}}
14+
/>,
15+
);
16+
17+
expect(
18+
screen.getByRole("button", { name: "Copy my-worker to clipboard" }),
19+
).toBeDefined();
20+
});
21+
});

‎packages/kumo/src/blocks/delete-resource/delete-resource.tsx‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useState, useCallback, useEffect } from "react";
1+
import { useState, useCallback, useEffect, useRef } from "react";
22
import {
33
Dialog,
44
DialogRoot,
@@ -15,6 +15,7 @@ import {
1515
XIcon,
1616
} from "@phosphor-icons/react";
1717
import { Banner } from "../../components/banner";
18+
import { useCopyFeedback } from "../../utils/use-copy-feedback";
1819

1920
export const KUMO_DELETE_RESOURCE_VARIANTS = {
2021
size: {
@@ -77,14 +78,16 @@ export function DeleteResource({
7778
className,
7879
}: DeleteResourceProps) {
7980
const [confirmationInput, setConfirmationInput] = useState("");
80-
const [copied, setCopied] = useState(false);
81+
const { copied, runCopy, reset: resetCopyFeedback } = useCopyFeedback(1500);
82+
const openRef = useRef(open);
83+
openRef.current = open;
8184

8285
useEffect(() => {
8386
if (!open) {
87+
resetCopyFeedback();
8488
setConfirmationInput("");
85-
setCopied(false);
8689
}
87-
}, [open]);
90+
}, [open, resetCopyFeedback]);
8891

8992
const normalizeForComparison = useCallback(
9093
(str: string) => (caseSensitive ? str : str.toLowerCase()),
@@ -101,10 +104,13 @@ export function DeleteResource({
101104
}, [isConfirmed, isDeleting, onDelete]);
102105

103106
const handleCopy = useCallback(async () => {
104-
await navigator.clipboard.writeText(resourceName);
105-
setCopied(true);
106-
setTimeout(() => setCopied(false), 1500);
107-
}, [resourceName]);
107+
if (!openRef.current) return;
108+
109+
await runCopy(
110+
() => navigator.clipboard.writeText(resourceName),
111+
(error) => console.warn("Clipboard copy failed", error),
112+
);
113+
}, [resourceName, runCopy]);
108114

109115
return (
110116
<DialogRoot open={open} onOpenChange={onOpenChange}>

‎packages/kumo/src/code/code-highlighted.tsx‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"use client";
22

3-
import React, { useState, useCallback, useEffect, useMemo } from "react";
3+
import React, { useCallback, useEffect, useMemo } from "react";
44
import { cn } from "../utils/cn";
5+
import { useCopyFeedback } from "../utils/use-copy-feedback";
56
import { Button } from "../components/button";
67
import { useShikiHighlighter } from "./use-shiki-highlighter";
78
import type { CodeHighlightedProps } from "./types";
@@ -47,7 +48,7 @@ export function CodeHighlighted({
4748
error,
4849
labels: providerLabels,
4950
} = useShikiHighlighter();
50-
const [copied, setCopied] = useState(false);
51+
const { copied, runCopy } = useCopyFeedback();
5152

5253
// Merge provider labels with component-level overrides
5354
const labels = useMemo(
@@ -56,14 +57,15 @@ export function CodeHighlighted({
5657
);
5758

5859
const handleCopy = useCallback(async () => {
59-
try {
60-
await navigator.clipboard.writeText(code);
61-
setCopied(true);
62-
setTimeout(() => setCopied(false), 2000);
63-
} catch (err) {
64-
console.error("[Kumo CodeHighlighted] Failed to copy to clipboard:", err);
65-
}
66-
}, [code]);
60+
await runCopy(
61+
() => navigator.clipboard.writeText(code),
62+
(error) =>
63+
console.error(
64+
"[Kumo CodeHighlighted] Failed to copy to clipboard:",
65+
error,
66+
),
67+
);
68+
}, [code, runCopy]);
6769

6870
// Memoized so unrelated rerenders (e.g. copy state) don't re-highlight
6971
const html = useMemo(() => highlight(code, lang), [highlight, code, lang]);

‎packages/kumo/src/components/breadcrumbs/breadcrumbs.tsx‎

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@ import {
22
Children,
33
cloneElement,
44
isValidElement,
5-
useEffect,
6-
useState,
75
type PropsWithChildren,
86
type ReactElement,
97
type ReactNode,
@@ -14,6 +12,7 @@ import { SkeletonLine } from "../../components/loader/skeleton-line";
1412
import { useLinkComponent } from "../../utils/link-provider";
1513
import { cn } from "../../utils/cn";
1614
import { resolveVariant } from "../../utils/resolve-variant";
15+
import { useCopyFeedback } from "../../utils/use-copy-feedback";
1716

1817
/** Breadcrumbs size variant definitions. */
1918
export const KUMO_BREADCRUMBS_VARIANTS = {
@@ -144,24 +143,15 @@ function MobileEllipsis() {
144143
}
145144

146145
function Clipboard({ text }: { text: string }) {
147-
const [isCopied, setIsCopied] = useState(false);
148-
149-
useEffect(() => {
150-
if (!isCopied) return;
151-
152-
const timeoutId = setTimeout(() => setIsCopied(false), 2000);
153-
return () => clearTimeout(timeoutId);
154-
}, [isCopied]);
146+
const { copied: isCopied, runCopy } = useCopyFeedback();
155147

156148
const handleCopyDeeplink = async () => {
157149
if (!text) return;
158150

159-
try {
160-
await navigator.clipboard.writeText(text);
161-
setIsCopied(true);
162-
} catch (err) {
163-
console.error("Failed to copy deeplink:", err);
164-
}
151+
await runCopy(
152+
() => navigator.clipboard.writeText(text),
153+
(error) => console.error("Failed to copy deeplink:", error),
154+
);
165155
};
166156

167157
return (

‎packages/kumo/src/components/empty/empty.tsx‎

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { CheckIcon, CopyIcon } from "@phosphor-icons/react";
2-
import { useState } from "react";
2+
import { useCallback } from "react";
33
import { Button } from "../../components/button";
44
import { Text } from "../../components/text";
55
import { cn } from "../../utils/cn";
66
import { resolveVariant } from "../../utils/resolve-variant";
7+
import { useCopyFeedback } from "../../utils/use-copy-feedback";
78

89
/** Empty state size variant definitions mapping sizes to their Tailwind classes. */
910
export const KUMO_EMPTY_VARIANTS = {
@@ -98,7 +99,16 @@ export function Empty({
9899
size = "base",
99100
className,
100101
}: EmptyProps) {
101-
const [emptyStateCopied, setEmptyStateCopied] = useState<boolean>(false);
102+
const { copied: emptyStateCopied, runCopy } = useCopyFeedback(1000);
103+
104+
const handleCopy = useCallback(async () => {
105+
if (!commandLine) return;
106+
107+
await runCopy(
108+
() => navigator.clipboard.writeText(commandLine),
109+
(error) => console.warn("Clipboard copy failed", error),
110+
);
111+
}, [commandLine, runCopy]);
102112

103113
return (
104114
<div className={cn(emptyVariants({ size }), className)}>
@@ -143,13 +153,7 @@ export function Empty({
143153
variant="ghost"
144154
shape="square"
145155
aria-label="Copy command"
146-
onClick={async () => {
147-
setEmptyStateCopied(true);
148-
setTimeout(() => {
149-
setEmptyStateCopied(false);
150-
}, 1000);
151-
await navigator.clipboard.writeText(commandLine);
152-
}}
156+
onClick={handleCopy}
153157
>
154158
{emptyStateCopied ? (
155159
<CheckIcon

0 commit comments

Comments
 (0)