diff --git a/ui/src/lib/clipboard.test.ts b/ui/src/lib/clipboard.test.ts index 08e5c9bc..5a7157e4 100644 --- a/ui/src/lib/clipboard.test.ts +++ b/ui/src/lib/clipboard.test.ts @@ -13,7 +13,6 @@ describe("copyTextToClipboard", () => { configurable: true, value: originalExecCommand, }); - document.querySelectorAll("textarea").forEach((element) => element.remove()); }); it("uses the Clipboard API when it is available", async () => { @@ -32,7 +31,15 @@ describe("copyTextToClipboard", () => { }); it("uses the DOM fallback when the Clipboard API is unavailable", async () => { - const execCommand = vi.fn().mockReturnValue(true); + const setData = vi.fn(); + const execCommand = vi.fn(() => { + const event = new Event("copy", { cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { setData }, + }); + document.dispatchEvent(event); + return true; + }); vi.stubGlobal("navigator", {}); Object.defineProperty(document, "execCommand", { configurable: true, @@ -42,12 +49,20 @@ describe("copyTextToClipboard", () => { await copyTextToClipboard("secret"); expect(execCommand).toHaveBeenCalledWith("copy"); - expect(document.querySelector("textarea")).toBeNull(); + expect(setData).toHaveBeenCalledWith("text/plain", "secret"); }); it("uses the DOM fallback when Clipboard API access is denied", async () => { const writeText = vi.fn().mockRejectedValue(new Error("denied")); - const execCommand = vi.fn().mockReturnValue(true); + const setData = vi.fn(); + const execCommand = vi.fn(() => { + const event = new Event("copy", { cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { setData }, + }); + document.dispatchEvent(event); + return true; + }); vi.stubGlobal("navigator", { clipboard: { writeText } }); Object.defineProperty(document, "execCommand", { configurable: true, @@ -58,9 +73,10 @@ describe("copyTextToClipboard", () => { expect(writeText).toHaveBeenCalledWith("secret"); expect(execCommand).toHaveBeenCalledWith("copy"); + expect(setData).toHaveBeenCalledWith("text/plain", "secret"); }); - it("rejects and removes the temporary element when copying fails", async () => { + it("rejects when copying fails", async () => { const execCommand = vi.fn().mockReturnValue(false); vi.stubGlobal("navigator", {}); Object.defineProperty(document, "execCommand", { @@ -71,6 +87,5 @@ describe("copyTextToClipboard", () => { await expect(copyTextToClipboard("secret")).rejects.toThrow( "Copy command was unsuccessful", ); - expect(document.querySelector("textarea")).toBeNull(); }); }); diff --git a/ui/src/lib/clipboard.ts b/ui/src/lib/clipboard.ts index dae92fff..f0c49b7d 100644 --- a/ui/src/lib/clipboard.ts +++ b/ui/src/lib/clipboard.ts @@ -12,22 +12,24 @@ export async function copyTextToClipboard(text: string): Promise { throw new Error("Clipboard access is unavailable"); } - const textArea = document.createElement("textarea"); - textArea.value = text; - textArea.setAttribute("readonly", ""); - textArea.style.position = "fixed"; - textArea.style.opacity = "0"; - textArea.style.pointerEvents = "none"; - document.body.appendChild(textArea); + let copiedText = false; + const handleCopy = (event: ClipboardEvent) => { + if (!event.clipboardData) { + return; + } + + event.clipboardData.setData("text/plain", text); + event.preventDefault(); + copiedText = true; + }; + + document.addEventListener("copy", handleCopy); try { - textArea.select(); - textArea.setSelectionRange(0, text.length); - - if (!document.execCommand("copy")) { + if (!document.execCommand("copy") || !copiedText) { throw new Error("Copy command was unsuccessful"); } } finally { - textArea.remove(); + document.removeEventListener("copy", handleCopy); } }