From a643f98f1a962ac292021354a940cfdd11588ffd Mon Sep 17 00:00:00 2001 From: Hammed Ali Oyeleye Date: Tue, 20 Oct 2026 00:23:58 +0100 Subject: [PATCH 1/2] feat(ui): add accessible animated copy feedback Implement clipboard success and failure feedback, reduced-motion support, keyboard accessibility, and wallet address copy integration. Verification: focused tests, full tests, coverage, build and integration checks passed. Lint and typecheck have identical pre-existing upstream failures. Changelog validation is blocked by upstream legacy entries; a PR-numbered entry will follow. Wallet-gated controls remain untested in a connected wallet. --- .../wallet/components/AccountBadge.tsx | 25 ++------ .../wallet/components/ConnectButton.tsx | 15 +++-- apps/web/src/ui/connect-button.tsx | 12 ++-- .../ui/src/components/copy-button.test.tsx | 64 ++++++++++++++++++- packages/ui/src/components/copy-button.tsx | 51 +++++++++++++-- packages/ui/src/components/tooltip.tsx | 1 + packages/ui/src/hooks/use-clipboard.test.ts | 40 ++++++++++-- packages/ui/src/hooks/use-clipboard.ts | 7 +- packages/ui/src/styles/globals.css | 9 +++ 9 files changed, 178 insertions(+), 46 deletions(-) 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 79c81954..9e72e35e 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 && (
- + ) 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 aa91915d..34748556 100644 --- a/packages/ui/src/styles/globals.css +++ b/packages/ui/src/styles/globals.css @@ -479,6 +479,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); From 2fe0e9d62b5d36e3505a050478ec75aa2fd9c9ae Mon Sep 17 00:00:00 2001 From: Hammed Ali Oyeleye Date: Tue, 20 Oct 2026 00:31:36 +0100 Subject: [PATCH 2/2] docs(changelog): add copy feedback entry for PR 1019 --- .changelog/unreleased/1019-copy-feedback.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changelog/unreleased/1019-copy-feedback.md 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.