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.