diff --git a/apps/app/src/components/generative-ui/__tests__/export-overlay.test.tsx b/apps/app/src/components/generative-ui/__tests__/export-overlay.test.tsx new file mode 100644 index 0000000..6021bc1 --- /dev/null +++ b/apps/app/src/components/generative-ui/__tests__/export-overlay.test.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { render, fireEvent, cleanup, act } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ExportOverlay } from "../export-overlay"; + +afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +function setup() { + return render(

Widget

); +} + +describe("ExportOverlay keyboard and clipboard", () => { + it("reveals options on keyboard focus and returns focus on Escape", () => { + const view = setup(); + const options = view.getByRole("button", { name: "Options" }); + fireEvent.focus(options); + expect(options.closest(".transition-opacity")?.getAttribute("style")).toContain("opacity: 1"); + expect(options.getAttribute("type")).toBe("button"); + fireEvent.click(options); + expect(options.getAttribute("aria-expanded")).toBe("true"); + const copy = view.getByRole("button", { name: "Copy to clipboard" }); + copy.focus(); fireEvent.keyDown(copy, { key: "Escape" }); + expect(view.queryByRole("button", { name: "Copy to clipboard" })).toBeNull(); + expect(document.activeElement).toBe(options); + expect(options.getAttribute("aria-expanded")).toBe("false"); + }); + + it("offers a download when clipboard access is unavailable", () => { + vi.stubGlobal("navigator", { clipboard: undefined }); + const view = setup(); fireEvent.click(view.getByRole("button", { name: "Options" })); + fireEvent.click(view.getByRole("button", { name: "Copy to clipboard" })); + expect(view.getByRole("status").textContent).toContain("Download the HTML file"); + expect(view.getByRole("button", { name: "Download file" })).toBeTruthy(); + }); + + it("reports denied clipboard access without silently closing the menu", async () => { + vi.stubGlobal("navigator", { clipboard: { writeText: vi.fn().mockRejectedValue(new Error("denied")) } }); + const view = setup(); fireEvent.click(view.getByRole("button", { name: "Options" })); + await act(async () => { fireEvent.click(view.getByRole("button", { name: "Copy to clipboard" })); }); + expect(view.getByRole("status").textContent).toContain("Copy failed"); + expect(view.getByRole("button", { name: "Download file" })).toBeTruthy(); + }); +}); diff --git a/apps/app/src/components/generative-ui/export-overlay.tsx b/apps/app/src/components/generative-ui/export-overlay.tsx index 7cedaca..0893c6a 100644 --- a/apps/app/src/components/generative-ui/export-overlay.tsx +++ b/apps/app/src/components/generative-ui/export-overlay.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useMemo, useRef, useEffect, type ReactNode } from "react"; +import { useState, useCallback, useMemo, useRef, useEffect, useId, type ReactNode } from "react"; import { chartToStandaloneHtml, triggerDownload, @@ -24,9 +24,12 @@ export function ExportOverlay({ ready = true, children, }: ExportOverlayProps) { - const [copyState, setCopyState] = useState<"idle" | "copied">("idle"); + const [copyState, setCopyState] = useState<"idle" | "copied" | "error">("idle"); const [menuOpen, setMenuOpen] = useState(false); const [hovered, setHovered] = useState(false); + const [focused, setFocused] = useState(false); + const triggerRef = useRef(null); + const menuId = useId(); const menuRef = useRef(null); // Close menu on outside click @@ -65,6 +68,11 @@ export function ExportOverlay({ const handleCopy = useCallback(() => { const textToCopy = exportHtml; if (!textToCopy) return; + setCopyState("idle"); + if (!navigator.clipboard?.writeText) { + setCopyState("error"); + return; + } navigator.clipboard.writeText(textToCopy).then( () => { setCopyState("copied"); @@ -72,20 +80,33 @@ export function ExportOverlay({ setTimeout(() => setCopyState("idle"), 1800); }, () => { - // Clipboard write failed (e.g. permission denied, iframe context) - setMenuOpen(false); + setCopyState("error"); } ); }, [exportHtml]); const exportable = ready && !!exportHtml; - const showTrigger = exportable && (hovered || menuOpen); + const showTrigger = exportable && (hovered || focused || menuOpen); return (
setHovered(true)} onMouseLeave={() => setHovered(false)} + onFocusCapture={() => setFocused(true)} + onBlurCapture={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { + setFocused(false); + setMenuOpen(false); + } + }} + onKeyDown={(event) => { + if (event.key === "Escape" && menuOpen) { + event.preventDefault(); + setMenuOpen(false); + triggerRef.current?.focus(); + } + }} > {exportable && (