Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changelog/unreleased/1019-copy-feedback.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 7 additions & 18 deletions apps/web/src/features/wallet/components/AccountBadge.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -88,15 +78,14 @@ export function AccountBadge({ address, className, ...props }: AccountBadgeProps
</p>
<p className="mt-2 wrap-break-word font-mono text-sm">{address}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="whitespace-nowrap"
onClick={handleCopy}
<CopyButton
value={address}
label="Copy address"
tone="outline"
onCopied={() => setOpen(false)}
>
Copy
</Button>
</CopyButton>
</div>
</div>

Expand Down
15 changes: 8 additions & 7 deletions apps/web/src/features/wallet/components/ConnectButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -220,16 +221,16 @@ function AccountBadge({
</div>

<div className="mt-2 space-y-0.5">
<button
<CopyButton
type="button"
className="w-full rounded-sm px-2 py-1.5 text-start text-sm transition-colors hover:bg-accent"
onClick={() => {
navigator.clipboard.writeText(address)
setOpen(false)
}}
role="menuitem"
className="w-full justify-start rounded-sm px-2 py-1.5 text-start text-sm transition-colors hover:bg-accent"
value={address}
label="Copy address"
onCopied={() => setOpen(false)}
>
Copy Address
</button>
</CopyButton>

{isTestnet && (
<a
Expand Down
64 changes: 63 additions & 1 deletion packages/ui/src/components/copy-button.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { act, cleanup, render, screen } from "@testing-library/react"
import { act, cleanup, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { axe } from "vitest-axe"
import { CopyButton } from "./copy-button"
Expand Down Expand Up @@ -78,6 +78,20 @@ describe("CopyButton", () => {
expect(status).toHaveTextContent("Copied!")
})

it("shows the copied feedback as a visible tooltip", async () => {
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime })
render(<CopyButton value="test" label="Copy" />)

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(
Expand All @@ -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(<CopyButton value="test" label="Copy" />)

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(<CopyButton value="test" label="Copy" resetAfter={500} />)
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 () => {
Expand Down
51 changes: 45 additions & 6 deletions packages/ui/src/components/copy-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ type CopyButtonTone = Exclude<
"link"
>

interface CopyButtonProps
extends Omit<React.ComponentProps<typeof Button>, "onClick" | "children"> {
interface CopyButtonProps extends Omit<
React.ComponentProps<typeof Button>,
"onClick" | "children"
> {
/** The value written to the clipboard on click. */
value: string
/** Accessible label (required for icon-only mode). */
Expand All @@ -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<ClipboardStatus, React.ReactNode> = {
Expand Down Expand Up @@ -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
Expand All @@ -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]}
<span
aria-hidden="true"
className="so4-copy-icon relative inline-flex size-3.5"
data-copy-status={status}
>
{(Object.keys(STATUS_ICON) as Array<ClipboardStatus>).map(
(iconStatus) => (
<span
key={iconStatus}
data-copy-icon={iconStatus}
className={cn(
"duration-fast absolute inset-0 inline-flex transition-[opacity,transform] ease-out motion-reduce:transition-none",
iconStatus === status
? "scale-100 opacity-100"
: "scale-75 opacity-0"
)}
>
{STATUS_ICON[iconStatus]}
</span>
)
)}
</span>
{children}
</Button>
)

if (status !== "idle") {
return (
<>
<span className="relative inline-flex">
{button}
<span
id={feedbackId}
className="so4-copy-feedback absolute start-1/2 bottom-full mb-1 -translate-x-1/2 rounded-md border border-border bg-popover px-2 py-1 text-xs whitespace-nowrap text-popover-foreground shadow-md"
role="tooltip"
>
{STATUS_TOOLTIP[status]}
</span>
<span className="sr-only" role="status" aria-live="polite">
{STATUS_TOOLTIP[status]}
</span>
</>
</span>
)
}

Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/components/tooltip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
40 changes: 33 additions & 7 deletions packages/ui/src/hooks/use-clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 () => {
Expand All @@ -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
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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<void>((resolve) => {
resolveFirst = resolve
})
writeText
.mockImplementationOnce(() => firstWrite)
.mockResolvedValueOnce(undefined)

const { result } = renderHook(() =>
useClipboard({ resetAfter: RESET_AFTER })
)
let firstCopy: Promise<boolean> | 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 () => {
Expand Down
Loading