diff --git a/.changelog/unreleased/1019-copy-feedback.md b/.changelog/unreleased/1019-copy-feedback.md new file mode 100644 index 00000000..af77428a --- /dev/null +++ b/.changelog/unreleased/1019-copy-feedback.md @@ -0,0 +1,8 @@ +--- +type: added +area: wallet +pr: 1019 +breaking: false +--- + +Copying wallet addresses and transaction hashes now provides animated feedback, accessible announcements, and clear error handling. diff --git a/apps/web/src/features/wallet/components/AccountBadge.tsx b/apps/web/src/features/wallet/components/AccountBadge.tsx index f9ff6084..62340033 100644 --- a/apps/web/src/features/wallet/components/AccountBadge.tsx +++ b/apps/web/src/features/wallet/components/AccountBadge.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState } from "react" import { Button } from "@workspace/ui/components/button" -import { toast } from "@workspace/ui/components/toast" +import { CopyButton } from "@workspace/ui/components/copy-button" import { cn } from "@workspace/ui/lib/utils" import { useBalance } from "../hooks/useBalance" @@ -48,16 +48,6 @@ export function AccountBadge({ address, className, ...props }: AccountBadgeProps } }, [open]) - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(address) - toast.success("Address copied") - setOpen(false) - } catch { - toast.error("Failed to copy address") - } - } - const handleDisconnect = () => { disconnect() setOpen(false) @@ -88,15 +78,14 @@ export function AccountBadge({ address, className, ...props }: AccountBadgeProps

{address}

- + diff --git a/apps/web/src/features/wallet/components/ConnectButton.tsx b/apps/web/src/features/wallet/components/ConnectButton.tsx index c2ea4f03..e9b1097a 100644 --- a/apps/web/src/features/wallet/components/ConnectButton.tsx +++ b/apps/web/src/features/wallet/components/ConnectButton.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react" import { QRCodeSVG } from "qrcode.react" import { Button } from "@workspace/ui/components/button" +import { CopyButton } from "@workspace/ui/components/copy-button" import { Dialog, DialogContent, @@ -220,16 +221,16 @@ function AccountBadge({
- + {isTestnet && ( { expect(status).toHaveTextContent("Copied!") }) + it("shows the copied feedback as a visible tooltip", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + render() + + await user.click(screen.getByRole("button", { name: "Copy" })) + + expect(screen.getByRole("tooltip")).toHaveTextContent("Copied!") + expect( + screen + .getByRole("button", { name: "Copy" }) + .querySelector('[data-copy-status="copied"]') + ).toBeInTheDocument() + }) + it("shows failure state when clipboard rejects", async () => { const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) vi.spyOn(navigator.clipboard, "writeText").mockRejectedValue( @@ -89,6 +103,54 @@ describe("CopyButton", () => { const status = screen.getByRole("status") expect(status).toHaveTextContent("Failed to copy") + expect(screen.queryByText("Copied!")).not.toBeInTheDocument() + expect(screen.getByRole("tooltip")).toHaveTextContent("Failed to copy") + }) + + it("supports keyboard activation and associates the feedback tooltip", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + const clipboardWrite = vi + .spyOn(navigator.clipboard, "writeText") + .mockResolvedValue(undefined) + render() + + const button = screen.getByRole("button", { name: "Copy" }) + button.focus() + await user.keyboard("{Enter}") + + await waitFor(() => expect(clipboardWrite).toHaveBeenCalledWith("test")) + const activeButton = screen.getByRole("button", { name: "Copy" }) + const tooltip = screen.getByRole("tooltip") + expect(activeButton).toHaveAttribute("aria-describedby", tooltip.id) + }) + + it("restarts feedback for an interrupted repeated copy", async () => { + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + const clipboardWrite = vi + .spyOn(navigator.clipboard, "writeText") + .mockResolvedValue(undefined) + render() + let button = screen.getByRole("button", { name: "Copy" }) + + await user.click(button) + button = screen.getByRole("button", { name: "Copy" }) + await waitFor(() => + expect( + button.querySelector('[data-copy-status="copied"]') + ).toBeInTheDocument() + ) + + await user.click(button) + button = screen.getByRole("button", { name: "Copy" }) + await waitFor(() => + expect( + button.querySelector('[data-copy-status="copied"]') + ).toBeInTheDocument() + ) + expect(clipboardWrite).toHaveBeenCalledTimes(2) + + act(() => vi.advanceTimersByTime(500)) + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument() }) it("resets state after timeout", async () => { diff --git a/packages/ui/src/components/copy-button.tsx b/packages/ui/src/components/copy-button.tsx index 47192197..1b766d83 100644 --- a/packages/ui/src/components/copy-button.tsx +++ b/packages/ui/src/components/copy-button.tsx @@ -19,8 +19,10 @@ type CopyButtonTone = Exclude< "link" > -interface CopyButtonProps - extends Omit, "onClick" | "children"> { +interface CopyButtonProps extends Omit< + React.ComponentProps, + "onClick" | "children" +> { /** The value written to the clipboard on click. */ value: string /** Accessible label (required for icon-only mode). */ @@ -34,6 +36,8 @@ interface CopyButtonProps tone?: CopyButtonTone /** Milliseconds before the status resets. @default 2000 */ resetAfter?: number + /** Called after the clipboard write succeeds. */ + onCopied?: () => void } const STATUS_ICON: Record = { @@ -95,12 +99,14 @@ function CopyButton({ children, tone = "ghost", resetAfter = 2000, + onCopied, className, disabled, ...props }: CopyButtonProps) { const { status, copy } = useClipboard({ resetAfter }) const isIconOnly = !children + const feedbackId = React.useId() const tooltipContent = status !== "idle" ? STATUS_TOOLTIP[status] : isIconOnly ? label : undefined @@ -114,22 +120,55 @@ function CopyButton({ data-slot="copy-button" disabled={disabled} className={cn(isIconOnly && "touch-target", className)} - onClick={() => void copy(value)} + aria-describedby={status !== "idle" ? feedbackId : undefined} + onClick={() => { + void copy(value).then((didCopy) => { + if (didCopy) onCopied?.() + }) + }} {...props} > - {STATUS_ICON[status]} + {children} ) if (status !== "idle") { return ( - <> + {button} + + {STATUS_TOOLTIP[status]} + {STATUS_TOOLTIP[status]} - + ) } diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx index 0184925d..472c7854 100644 --- a/packages/ui/src/components/tooltip.tsx +++ b/packages/ui/src/components/tooltip.tsx @@ -67,6 +67,7 @@ function TooltipContent({ "z-50 max-w-xs origin-(--transform-origin) rounded-md bg-popover px-3 py-1.5 text-xs text-popover-foreground ring-1 ring-foreground/10 shadow-md has-data-[slot=tooltip-description]:flex-col has-data-[slot=tooltip-description]:items-start has-data-[slot=tooltip-description]:gap-0 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-inline-start-2 data-[side=inline-start]:slide-in-from-inline-end-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:ms-auto **:data-[slot=kbd]:rounded-sm **:data-[slot=kbd]:border **:data-[slot=kbd]:border-border **:data-[slot=kbd]:bg-muted **:data-[slot=kbd]:px-1 **:data-[slot=kbd]:py-0.5 **:data-[slot=kbd]:text-10 **:data-[slot=kbd]:font-mono **:data-[slot=kbd]:leading-none ", className )} + role="tooltip" {...props} > {children} diff --git a/packages/ui/src/hooks/use-clipboard.test.ts b/packages/ui/src/hooks/use-clipboard.test.ts index 1abe6ddf..944ede16 100644 --- a/packages/ui/src/hooks/use-clipboard.test.ts +++ b/packages/ui/src/hooks/use-clipboard.test.ts @@ -32,14 +32,14 @@ describe("useClipboard", () => { it("starts in idle status", () => { const { result } = renderHook(() => - useClipboard({ resetAfter: RESET_AFTER }), + useClipboard({ resetAfter: RESET_AFTER }) ) expect(result.current.status).toBe("idle") }) it("sets status to copied on success", async () => { const { result } = renderHook(() => - useClipboard({ resetAfter: RESET_AFTER }), + useClipboard({ resetAfter: RESET_AFTER }) ) let ok = false @@ -54,7 +54,7 @@ describe("useClipboard", () => { it("resets to idle after the timeout", async () => { const { result } = renderHook(() => - useClipboard({ resetAfter: RESET_AFTER }), + useClipboard({ resetAfter: RESET_AFTER }) ) await act(async () => { @@ -71,7 +71,7 @@ describe("useClipboard", () => { it("sets status to failed when clipboard rejects", async () => { writeText.mockRejectedValue(new Error("denied")) const { result } = renderHook(() => - useClipboard({ resetAfter: RESET_AFTER }), + useClipboard({ resetAfter: RESET_AFTER }) ) let ok = true @@ -86,7 +86,7 @@ describe("useClipboard", () => { it("resets failed status after the timeout", async () => { writeText.mockRejectedValue(new Error("denied")) const { result } = renderHook(() => - useClipboard({ resetAfter: RESET_AFTER }), + useClipboard({ resetAfter: RESET_AFTER }) ) await act(async () => { @@ -102,7 +102,7 @@ describe("useClipboard", () => { it("clears previous timer on repeated clicks", async () => { const { result } = renderHook(() => - useClipboard({ resetAfter: RESET_AFTER }), + useClipboard({ resetAfter: RESET_AFTER }) ) await act(async () => { @@ -130,9 +130,35 @@ describe("useClipboard", () => { expect(result.current.status).toBe("idle") }) + it("ignores a stale clipboard result when a newer copy starts", async () => { + let resolveFirst: (() => void) | undefined + const firstWrite = new Promise((resolve) => { + resolveFirst = resolve + }) + writeText + .mockImplementationOnce(() => firstWrite) + .mockResolvedValueOnce(undefined) + + const { result } = renderHook(() => + useClipboard({ resetAfter: RESET_AFTER }) + ) + let firstCopy: Promise | undefined + await act(async () => { + firstCopy = result.current.copy("first") + await result.current.copy("second") + }) + + expect(result.current.status).toBe("copied") + resolveFirst?.() + await act(async () => { + await firstCopy + }) + expect(result.current.status).toBe("copied") + }) + it("cleans up timer on unmount", async () => { const { result, unmount } = renderHook(() => - useClipboard({ resetAfter: RESET_AFTER }), + useClipboard({ resetAfter: RESET_AFTER }) ) await act(async () => { diff --git a/packages/ui/src/hooks/use-clipboard.ts b/packages/ui/src/hooks/use-clipboard.ts index 63f5c06f..bc4113fb 100644 --- a/packages/ui/src/hooks/use-clipboard.ts +++ b/packages/ui/src/hooks/use-clipboard.ts @@ -15,6 +15,7 @@ export function useClipboard({ }: UseClipboardOptions = {}) { const [status, setStatus] = useState("idle") const timerRef = useRef | null>(null) + const operationRef = useRef(0) const clearTimer = useCallback(() => { if (timerRef.current) { @@ -26,9 +27,12 @@ export function useClipboard({ const copy = useCallback( async (text: string) => { clearTimer() + const operation = operationRef.current + 1 + operationRef.current = operation try { await navigator.clipboard.writeText(text) + if (operation !== operationRef.current) return false setStatus("copied") timerRef.current = setTimeout(() => { setStatus("idle") @@ -36,6 +40,7 @@ export function useClipboard({ }, resetAfter) return true } catch { + if (operation !== operationRef.current) return false setStatus("failed") timerRef.current = setTimeout(() => { setStatus("idle") @@ -44,7 +49,7 @@ export function useClipboard({ return false } }, - [clearTimer, resetAfter], + [clearTimer, resetAfter] ) useEffect(() => clearTimer, [clearTimer]) diff --git a/packages/ui/src/styles/globals.css b/packages/ui/src/styles/globals.css index c738bb3c..bc8ff2b0 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -474,6 +474,15 @@ animation: none !important; } } + +@media (forced-colors: active) { + .so4-copy-feedback { + background: Canvas; + border-color: CanvasText; + box-shadow: none; + color: CanvasText; + } +} :root { --background: oklch(1 0 0); --foreground: oklch(0.145 0 0);