diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx new file mode 100644 index 0000000..cea585f --- /dev/null +++ b/src/components/CopyButton.tsx @@ -0,0 +1,37 @@ +import { useState, useCallback } from "react"; +import { VARIANT_CLASSES } from "./badge-utils"; + +// Copy-to-clipboard pill, styled to match the analyzer's "Copy Link" button: flips to a green +// "Copied" state for 1.5s after a click. aria-label defaults to the visible label. +export function CopyButton({ + value, + label = "Copy", + copiedLabel = "Copied", + ariaLabel, + className, +}: { + value: string; + label?: string; + copiedLabel?: string; + ariaLabel?: string; + className?: string; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, [value]); + + return ( + + ); +} diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index 7dba588..57a62e0 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { getNode, getNodeObservations, getNodeNeighbors } from "../../api/client"; import { Badge } from "../../components/Badge"; import { DetailPanel, Section, Field } from "../../components/DetailPanel"; +import { CopyButton } from "../../components/CopyButton"; import { IataChip } from "../../components/IataChip"; import { formatHex, formatSnr, snrLevel, formatRadio, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; @@ -116,8 +117,11 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o {node.nodeTypeName} -
- {node.publicKey} +
+
+ {node.publicKey} +
+
{node.observerId && (
-
- {observer.publicKey} +
+
+ {observer.publicKey} +
+
diff --git a/tests/components/CopyButton.test.tsx b/tests/components/CopyButton.test.tsx new file mode 100644 index 0000000..3d5fb4a --- /dev/null +++ b/tests/components/CopyButton.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { CopyButton } from "../../src/components/CopyButton"; + +const writeText = vi.fn(); + +beforeEach(() => { + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + writable: true, + configurable: true, + }); + writeText.mockClear(); +}); + +describe("CopyButton", () => { + it("shows the default 'Copy' label", () => { + render(); + expect(screen.getByRole("button")).toHaveTextContent("Copy"); + }); + + it("writes the full value to the clipboard on click", () => { + const key = "0123456789abcdef0123456789abcdef"; + render(); + fireEvent.click(screen.getByRole("button")); + expect(writeText).toHaveBeenCalledWith(key); + }); + + it("swaps to 'Copied' after clicking, then reverts", () => { + vi.useFakeTimers(); + try { + render(); + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(button).toHaveTextContent("Copied"); + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(button).toHaveTextContent("Copy"); + } finally { + vi.useRealTimers(); + } + }); + + it("uses the provided aria-label for the accessible name", () => { + render(); + expect(screen.getByRole("button", { name: "Copy public key" })).toBeInTheDocument(); + }); +});