diff --git a/src/components/feed/PollDisplay.test.tsx b/src/components/feed/PollDisplay.test.tsx
new file mode 100644
index 00000000..1a4bee33
--- /dev/null
+++ b/src/components/feed/PollDisplay.test.tsx
@@ -0,0 +1,74 @@
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { PollDisplay } from "./PollDisplay";
+
+const initialResults = {
+ options: [
+ { id: "option-1", text: "First", votes: 1, percentage: 100 },
+ { id: "option-2", text: "Second", votes: 0, percentage: 0 },
+ ],
+ total_votes: 1,
+ user_vote: "option-1",
+};
+
+const changedResults = {
+ options: [
+ { id: "option-1", text: "First", votes: 0, percentage: 0 },
+ { id: "option-2", text: "Second", votes: 1, percentage: 100 },
+ ],
+ total_votes: 1,
+ user_vote: "option-2",
+};
+
+function jsonResponse(body: unknown) {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("PollDisplay", () => {
+ beforeEach(() => {
+ vi.stubGlobal("fetch", vi.fn());
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ });
+
+ it("lets an authenticated viewer change an existing vote", async () => {
+ vi.mocked(fetch)
+ .mockResolvedValueOnce(jsonResponse(initialResults))
+ .mockResolvedValueOnce(jsonResponse({ success: true }))
+ .mockResolvedValueOnce(jsonResponse(changedResults));
+
+ render();
+ await userEvent.click(await screen.findByRole("radio", { name: /Second/ }));
+
+ expect(fetch).toHaveBeenNthCalledWith(
+ 2,
+ "/api/posts/post-1/poll/vote",
+ expect.objectContaining({
+ method: "POST",
+ body: JSON.stringify({ option_id: "option-2" }),
+ })
+ );
+ await waitFor(() => {
+ expect(screen.getByRole("radio", { name: /Second/ })).toHaveAttribute(
+ "aria-checked",
+ "true"
+ );
+ });
+ });
+
+ it("does not submit the already-selected option again", async () => {
+ vi.mocked(fetch).mockResolvedValueOnce(jsonResponse(initialResults));
+
+ render();
+ await userEvent.click(await screen.findByRole("radio", { name: /First/ }));
+
+ expect(fetch).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/components/feed/PollDisplay.tsx b/src/components/feed/PollDisplay.tsx
index 6a28212b..276c3d47 100644
--- a/src/components/feed/PollDisplay.tsx
+++ b/src/components/feed/PollDisplay.tsx
@@ -37,7 +37,7 @@ export function PollDisplay({ postId, isLoggedIn }: PollDisplayProps) {
}, [postId]);
const handleVote = async (optionId: string) => {
- if (!isLoggedIn || voting) return;
+ if (!isLoggedIn || voting || optionId === userVote) return;
setVoting(true);
try {
@@ -74,20 +74,19 @@ export function PollDisplay({ postId, isLoggedIn }: PollDisplayProps) {
const showResults = hasVoted || !isLoggedIn;
return (
-
e.stopPropagation()}>
+
e.stopPropagation()}
+ role={showResults && isLoggedIn ? "radiogroup" : undefined}
+ aria-label={showResults && isLoggedIn ? "Poll choices" : undefined}
+ >
{options.map((option) => {
const isSelected = userVote === option.id;
const isWinner = showResults && option.votes === Math.max(...options.map((o) => o.votes)) && option.votes > 0;
if (showResults) {
- return (
-
+ const result = (
+ <>
{/* Background bar */}
+ >
+ );
+ const className = cn(
+ "relative rounded-md border overflow-hidden transition-colors",
+ isSelected ? "border-primary" : "border-border"
+ );
+
+ return isLoggedIn ? (
+
+ ) : (
+
+ {result}
);
}