diff --git a/app/components/live-region.spec.tsx b/app/components/live-region.spec.tsx
new file mode 100644
index 000000000..9cdbc78ce
--- /dev/null
+++ b/app/components/live-region.spec.tsx
@@ -0,0 +1,101 @@
+import { act, cleanup, render, screen } from "@testing-library/react";
+import { StrictMode } from "react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { LiveRegion } from "@/components/live-region";
+
+beforeEach(() => {
+ vi.useFakeTimers();
+});
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+});
+
+describe("LiveRegion", () => {
+ it("renders a polite, atomic status region", () => {
+ render();
+ const region = screen.getByRole("status");
+
+ expect(region.getAttribute("aria-live")).toBe("polite");
+ expect(region.getAttribute("aria-atomic")).toBe("true");
+ });
+
+ it("does not announce the initial message, even in StrictMode", () => {
+ render(
+
+
+
+ );
+ act(() => {
+ vi.runAllTimers();
+ });
+
+ expect(screen.getByRole("status").textContent).toBe("");
+ });
+
+ it("announces a changed message only after the delay", () => {
+ const { rerender } = render(
+
+ );
+ rerender();
+
+ act(() => {
+ vi.advanceTimersByTime(499);
+ });
+ expect(screen.getByRole("status").textContent).toBe("");
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ expect(screen.getByRole("status").textContent).toBe("3 results");
+ });
+
+ it("only announces the last message of a quick succession", () => {
+ const { rerender } = render();
+ rerender();
+ act(() => {
+ vi.advanceTimersByTime(200);
+ });
+ rerender();
+ act(() => {
+ vi.runAllTimers();
+ });
+
+ expect(screen.getByRole("status").textContent).toBe("1 result");
+ });
+
+ it("announces a message that changes back to the initial one", () => {
+ const { rerender } = render();
+ rerender();
+ act(() => {
+ vi.runAllTimers();
+ });
+ rerender();
+ act(() => {
+ vi.runAllTimers();
+ });
+
+ expect(screen.getByRole("status").textContent).toBe("A");
+ });
+
+ it("keeps a pending announcement when the delay changes", () => {
+ const { rerender } = render();
+ rerender();
+ rerender();
+ act(() => {
+ vi.advanceTimersByTime(300);
+ });
+
+ expect(screen.getByRole("status").textContent).toBe("B");
+ });
+
+ it("cancels a pending announcement on unmount", () => {
+ const { rerender, unmount } = render();
+ rerender();
+ unmount();
+
+ expect(vi.getTimerCount()).toBe(0);
+ });
+});
diff --git a/app/components/live-region.tsx b/app/components/live-region.tsx
new file mode 100644
index 000000000..2637779c6
--- /dev/null
+++ b/app/components/live-region.tsx
@@ -0,0 +1,50 @@
+import { useEffect, useRef, useState } from "react";
+
+import { VisuallyHidden } from "@/components/visually-hidden";
+
+const DEFAULT_DELAY_MS = 500;
+
+export const LiveRegion = ({
+ message,
+ delay = DEFAULT_DELAY_MS,
+}: {
+ message: string;
+ delay?: number;
+}) => {
+ const announcement = useDebouncedAnnouncement({ message, delay });
+
+ return (
+
+ {announcement}
+
+ );
+};
+
+const useDebouncedAnnouncement = ({
+ message,
+ delay,
+}: {
+ message: string;
+ delay: number;
+}) => {
+ const [announcement, setAnnouncement] = useState("");
+ const initialMessageRef = useRef(message);
+
+ useEffect(() => {
+ if (message === initialMessageRef.current) {
+ return;
+ }
+
+ initialMessageRef.current = null;
+ const timeout = setTimeout(() => setAnnouncement(message), delay);
+
+ return () => clearTimeout(timeout);
+ }, [message, delay]);
+
+ return announcement;
+};
diff --git a/app/components/visually-hidden.spec.tsx b/app/components/visually-hidden.spec.tsx
new file mode 100644
index 000000000..b5fd7a7f6
--- /dev/null
+++ b/app/components/visually-hidden.spec.tsx
@@ -0,0 +1,30 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import { VisuallyHidden } from "@/components/visually-hidden";
+import { expectNoAxeViolations } from "@/test/axe";
+
+afterEach(cleanup);
+
+describe("VisuallyHidden", () => {
+ it("renders a div by default", () => {
+ const { container } = render(Hidden);
+
+ expect(container.firstElementChild?.tagName).toBe("DIV");
+ });
+
+ it("renders a span inside a button and keeps its text in the name", async () => {
+ const { container } = render(
+
+ );
+
+ expect(container.querySelector("button > span")).not.toBeNull();
+ expect(
+ screen.getByRole("button", { name: /^Show more ?: Canton$/ })
+ ).toBeTruthy();
+ await expectNoAxeViolations(container);
+ });
+});
diff --git a/app/components/visually-hidden.tsx b/app/components/visually-hidden.tsx
index 09a382e20..3cad5c8e8 100644
--- a/app/components/visually-hidden.tsx
+++ b/app/components/visually-hidden.tsx
@@ -1,8 +1,8 @@
-import { Box } from "@mui/material";
+import { Box, BoxProps } from "@mui/material";
import { visuallyHidden } from "@mui/utils";
-import { ReactNode } from "react";
-export const VisuallyHidden = ({ children }: { children: ReactNode }) => {
- // @ts-ignore - I do not know why CSSProperties do not go directly into sx. It works.
- return {children};
+type VisuallyHiddenProps = Omit;
+
+export const VisuallyHidden = (props: VisuallyHiddenProps) => {
+ return ;
};
diff --git a/app/package.json b/app/package.json
index d163521d6..db1e05cee 100644
--- a/app/package.json
+++ b/app/package.json
@@ -189,6 +189,7 @@
]
},
"devDependencies": {
+ "@axe-core/playwright": "^4.14.0",
"@graphql-codegen/cli": "^6",
"@graphql-codegen/typescript": "^6.0.1",
"@graphql-codegen/typescript-operations": "^6.0.2",
@@ -245,6 +246,7 @@
"@types/wellknown": "^0.5.1",
"@vitejs/plugin-react": "^4.5.0",
"apollo-server-plugin-base": "3",
+ "axe-core": "^4.14.0",
"babel-core": "^7.0.0-bridge.0",
"babel-plugin-module-resolver": "^4.1.0",
"eslint-plugin-unused-imports": "^4.1.4",
diff --git a/app/test/axe-common.ts b/app/test/axe-common.ts
new file mode 100644
index 000000000..e0183bf71
--- /dev/null
+++ b/app/test/axe-common.ts
@@ -0,0 +1,14 @@
+import type { Result, TagValue } from "axe-core";
+
+export const WCAG_TAGS: TagValue[] = [
+ "wcag2a",
+ "wcag2aa",
+ "wcag21a",
+ "wcag21aa",
+];
+
+export const formatViolation = (violation: Result) => {
+ const targets = violation.nodes.map((node) => node.target.join(" "));
+
+ return `${violation.id}: ${violation.help} (${targets.join(", ")})`;
+};
diff --git a/app/test/axe.ts b/app/test/axe.ts
new file mode 100644
index 000000000..f9cd68555
--- /dev/null
+++ b/app/test/axe.ts
@@ -0,0 +1,15 @@
+import axe from "axe-core";
+import { expect } from "vitest";
+
+import { formatViolation, WCAG_TAGS } from "@/test/axe-common";
+
+export const expectNoAxeViolations = async (container: Element) => {
+ const { violations } = await axe.run(container, {
+ runOnly: { type: "tag", values: WCAG_TAGS },
+ rules: {
+ "color-contrast": { enabled: false },
+ },
+ });
+
+ expect(violations.map(formatViolation)).toEqual([]);
+};
diff --git a/e2e/a11y.ts b/e2e/a11y.ts
new file mode 100644
index 000000000..3113c1057
--- /dev/null
+++ b/e2e/a11y.ts
@@ -0,0 +1,22 @@
+import AxeBuilder from "@axe-core/playwright";
+import { expect, Page } from "@playwright/test";
+
+import { formatViolation, WCAG_TAGS } from "../app/test/axe-common";
+
+export const expectNoAxeViolations = async ({
+ page,
+ include,
+}: {
+ page: Page;
+ include?: string;
+}) => {
+ const builder = new AxeBuilder({ page }).withTags(WCAG_TAGS);
+
+ if (include) {
+ builder.include(include);
+ }
+
+ const { violations } = await builder.analyze();
+
+ expect(violations.map(formatViolation)).toEqual([]);
+};
diff --git a/knip.config.ts b/knip.config.ts
index 736b81475..e490c6452 100644
--- a/knip.config.ts
+++ b/knip.config.ts
@@ -45,6 +45,7 @@ const config: KnipConfig = {
"pngjs",
"@types/pngjs",
"@playwright/test",
+ "@axe-core/playwright",
// Codegen packages are used through scripts.
"@graphql-codegen/*",
"@lingui/cli",
diff --git a/yarn.lock b/yarn.lock
index 142078f7c..3c6ec7908 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -177,6 +177,13 @@
"@csstools/css-tokenizer" "^3.0.3"
lru-cache "^10.4.3"
+"@axe-core/playwright@^4.14.0":
+ version "4.14.0"
+ resolved "https://registry.yarnpkg.com/@axe-core/playwright/-/playwright-4.14.0.tgz#a137c84ad18cf1ec17b5458f300a6e370444fbe5"
+ integrity sha512-SGUhG5PIHd2eMdpgBF2PyQLOhX+qyDR2PT4cmoS4Qk2ak+jX/rrSvst9l0PGv5WTHolKpUMvVmoHpr4cZgf1RQ==
+ dependencies:
+ axe-core "~4.14.0"
+
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.29.0", "@babel/code-frame@^7.29.7", "@babel/code-frame@^7.5.5":
version "7.29.7"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7"
@@ -9450,6 +9457,11 @@ axe-core@^4.10.0:
resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.2.tgz#85228e3e1d8b8532a27659b332e39b7fa0e022df"
integrity sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==
+axe-core@^4.14.0, axe-core@~4.14.0:
+ version "4.14.0"
+ resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.14.0.tgz#a9819d3932a8198fbe935d8cbd8afcdfecbcf31d"
+ integrity sha512-9WTZxEjsZ7b13TH8JPmbV2z8CHbl80/2hm3XPEG4JgNdQLK81IBRXmSxHfMAOkSqQeRxT/0dwNDz2GOm3zzpcQ==
+
axobject-query@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee"