Explorar o código

[Phase 4 U6] Add RollAnnouncer accessibility live region

Visually-hidden aria-live="polite" region that announces dice-roll
state to screen readers. Bumps a counter on each transition into
"complete" and uses it as the inner span's key so React mounts a
fresh node, forcing screen readers to re-announce identical winners
on a re-roll. Movie titles render as React text children only — no
dangerouslySetInnerHTML, no unescaped title= attributes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User hai 2 meses
pai
achega
2a1e80f83e

+ 124 - 0
src/__tests__/components/dice/roll-announcer.test.tsx

@@ -0,0 +1,124 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { render, cleanup } from "@testing-library/react";
+import { RollAnnouncer } from "@/components/dice/roll-announcer";
+import type { Movie } from "@/types/movie";
+
+afterEach(cleanup);
+
+function makeMovie(overrides: Partial<Movie> = {}): Movie {
+  return {
+    id: "movie-1",
+    group_id: "group-1",
+    tmdb_id: 1,
+    title: "Test Movie",
+    year: 2024,
+    poster_path: null,
+    genres: [],
+    trailer_url: null,
+    trailer_url_refreshed_at: null,
+    metadata_refreshed_at: null,
+    added_by: null,
+    watched: false,
+    watched_at: null,
+    added_at: "2024-01-01T00:00:00Z",
+    ...overrides,
+  };
+}
+
+describe("RollAnnouncer", () => {
+  it("renders 'Rolling…' when state is 'rolling'", () => {
+    const { container } = render(
+      <RollAnnouncer state="rolling" winner={null} />,
+    );
+    expect(container.textContent).toBe("Rolling…");
+  });
+
+  it("renders 'Rolled <title> (<year>)' when state is 'complete' with a winner", () => {
+    const winner = makeMovie({ title: "Inception", year: 2010 });
+    const { container } = render(
+      <RollAnnouncer state="complete" winner={winner} />,
+    );
+    expect(container.textContent).toBe("Rolled Inception (2010)");
+  });
+
+  it("renders empty when state is 'idle'", () => {
+    const { container } = render(<RollAnnouncer state="idle" winner={null} />);
+    expect(container.textContent).toBe("");
+  });
+
+  it("renders empty when state is 'complete' but no winner", () => {
+    const { container } = render(
+      <RollAnnouncer state="complete" winner={null} />,
+    );
+    expect(container.textContent).toBe("");
+  });
+
+  it("uses an sr-only live region with aria-live='polite' and aria-atomic='true'", () => {
+    const { container } = render(
+      <RollAnnouncer state="rolling" winner={null} />,
+    );
+    const region = container.querySelector("[aria-live]");
+    expect(region).not.toBeNull();
+    expect(region?.getAttribute("aria-live")).toBe("polite");
+    expect(region?.getAttribute("aria-atomic")).toBe("true");
+    expect(region?.className).toContain("sr-only");
+  });
+
+  it("increments the announce-id counter on each transition into 'complete' (forces re-announce)", () => {
+    const first = makeMovie({ id: "a", title: "First", year: 2001 });
+    const second = makeMovie({ id: "b", title: "Second", year: 2002 });
+
+    const { container, rerender } = render(
+      <RollAnnouncer state="idle" winner={null} />,
+    );
+
+    // Idle: counter starts at 0
+    let announceId = container
+      .querySelector("[data-announce-id]")
+      ?.getAttribute("data-announce-id");
+    expect(announceId).toBe("0");
+
+    // First roll: idle -> rolling -> complete (counter -> 1)
+    rerender(<RollAnnouncer state="rolling" winner={null} />);
+    rerender(<RollAnnouncer state="complete" winner={first} />);
+
+    announceId = container
+      .querySelector("[data-announce-id]")
+      ?.getAttribute("data-announce-id");
+    expect(announceId).toBe("1");
+    expect(container.textContent).toBe("Rolled First (2001)");
+
+    // Re-roll: complete -> rolling -> complete (counter -> 2)
+    rerender(<RollAnnouncer state="rolling" winner={null} />);
+    rerender(<RollAnnouncer state="complete" winner={second} />);
+
+    announceId = container
+      .querySelector("[data-announce-id]")
+      ?.getAttribute("data-announce-id");
+    expect(announceId).toBe("2");
+    expect(container.textContent).toBe("Rolled Second (2002)");
+  });
+
+  it("renders movie title as text only — no HTML injection (XSS guard)", () => {
+    const evil = makeMovie({
+      title: "<script>alert(1)</script>",
+      year: 2024,
+    });
+    const { container } = render(
+      <RollAnnouncer state="complete" winner={evil} />,
+    );
+
+    // No <script> element should ever land in the DOM.
+    expect(container.querySelector("script")).toBeNull();
+
+    // The raw string is rendered as text content.
+    expect(container.textContent).toBe(
+      "Rolled <script>alert(1)</script> (2024)",
+    );
+
+    // Confirm it lives in a text node, not parsed HTML.
+    const span = container.querySelector("[data-announce-id]");
+    expect(span?.children.length).toBe(0);
+    expect(span?.textContent).toBe("Rolled <script>alert(1)</script> (2024)");
+  });
+});

+ 46 - 0
src/components/dice/roll-announcer.tsx

@@ -0,0 +1,46 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import type { Movie } from "@/types/movie";
+import type { RollState } from "@/hooks/use-roll";
+
+export interface RollAnnouncerProps {
+  state: RollState;
+  winner: Movie | null;
+}
+
+/**
+ * Visually-hidden live region that announces dice-roll state to screen readers.
+ *
+ * Why the key/counter pattern: identical repeated `aria-live` text is silently
+ * swallowed by most screen readers. To force a re-announcement on every re-roll,
+ * we bump a counter on each transition into "complete" and use it as the `key`
+ * of the inner span so React mounts a fresh node — even when the message text
+ * happens to be byte-identical to the previous announcement.
+ */
+export function RollAnnouncer({ state, winner }: RollAnnouncerProps) {
+  const [completeCounter, setCompleteCounter] = useState(0);
+  const prevStateRef = useRef<RollState>(state);
+
+  useEffect(() => {
+    if (state === "complete" && prevStateRef.current !== "complete") {
+      setCompleteCounter((n) => n + 1);
+    }
+    prevStateRef.current = state;
+  }, [state]);
+
+  let message = "";
+  if (state === "rolling") {
+    message = "Rolling…";
+  } else if (state === "complete" && winner) {
+    message = `Rolled ${winner.title} (${winner.year})`;
+  }
+
+  return (
+    <div aria-live="polite" aria-atomic="true" className="sr-only">
+      <span key={completeCounter} data-announce-id={completeCounter}>
+        {message}
+      </span>
+    </div>
+  );
+}