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]}
+
+ {(Object.keys(STATUS_ICON) as Array).map(
+ (iconStatus) => (
+
+ {STATUS_ICON[iconStatus]}
+
+ )
+ )}
+
{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);