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"