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
65 changes: 65 additions & 0 deletions src/__tests__/CopyLinkButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import CopyLinkButton from "@/components/CopyLinkButton";

const URL = "https://stellarsplit.app/i/42";

function mockClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
writable: true,
});
return writeText;
}

describe("CopyLinkButton", () => {
afterEach(() => {
vi.restoreAllMocks();
});

it("starts in the idle state", () => {
mockClipboard();
render(<CopyLinkButton url={URL} />);

expect(
screen.getByRole("button", { name: /copy verification link/i })
).toBeInTheDocument();
expect(screen.getByText("Copy Link")).toBeInTheDocument();
});

it("switches to the success state and announces after a successful copy", async () => {
const writeText = mockClipboard();
render(<CopyLinkButton url={URL} />);

fireEvent.click(
screen.getByRole("button", { name: /copy verification link/i })
);

await waitFor(() =>
expect(screen.getByText("Copied!")).toBeInTheDocument()
);

expect(writeText).toHaveBeenCalledWith(URL);

// Icon swap: the checkmark replaces the link icon.
const button = screen.getByRole("button", { name: /copied/i });
expect(button.querySelector("svg")).toBeTruthy();

// Green success styling for the 2s window.
expect(button.className).toContain("bg-green-600");

// Screen-reader announcement.
expect(screen.getByText("Link copied")).toBeInTheDocument();
});

it("exposes a polite live region for the announcement", () => {
mockClipboard();
const { container } = render(<CopyLinkButton url={URL} />);

const live = container.querySelector('[aria-live="polite"]');
expect(live).not.toBeNull();
expect(live).toHaveTextContent("");
});
});
56 changes: 49 additions & 7 deletions src/components/CopyLinkButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,54 @@ export default function CopyLinkButton({ url }: { url: string }) {
const { copy, copied } = useCopyToClipboard();

return (
<button
onClick={() => copy(url)}
className="px-3 py-1.5 rounded-lg bg-gray-700 hover:bg-gray-600 text-white text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500"
aria-label={copied ? "Copied" : "Copy verification link"}
>
{copied ? "Copied!" : "Copy Link"}
</button>
<>
<button
type="button"
onClick={() => copy(url)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-white text-sm transition-colors duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${
copied
? "bg-green-600 hover:bg-green-600"
: "bg-gray-700 hover:bg-gray-600"
}`}
aria-label={copied ? "Copied" : "Copy verification link"}
>
{copied ? (
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
) : (
<svg
className="w-4 h-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
</svg>
)}
{copied ? "Copied!" : "Copy Link"}
</button>

<span aria-live="polite" className="sr-only">
{copied ? "Link copied" : ""}
</span>
</>
);
}