217 lines
7.7 KiB
TypeScript
217 lines
7.7 KiB
TypeScript
/**
|
|
* Accessibility test helper used by `*.a11y.test.tsx` suites.
|
|
*
|
|
* This is intentionally a lightweight, dependency-free static a11y checker.
|
|
* Browser-level WCAG auditing (axe-core via Playwright) is owned by E2E.
|
|
*
|
|
* The helper renders a React node into jsdom via `@testing-library/react`
|
|
* and walks the resulting DOM looking for a curated set of high-signal
|
|
* violations that we never want to regress:
|
|
*
|
|
* - `<img>` without `alt` (decorative images must use `alt=""`).
|
|
* - Interactive controls without an accessible name
|
|
* (button / role=button / role=link with no text + no aria-label
|
|
* + no aria-labelledby + no title).
|
|
* - Form inputs without an accessible label (input/select/textarea
|
|
* with no associated <label>, no aria-label, no aria-labelledby,
|
|
* no title, no placeholder is NOT enough).
|
|
* - `<a>` elements with `href` but no discernible text content
|
|
* and no aria-label.
|
|
* - `tabindex` values greater than 0 (positive tabindex breaks
|
|
* natural keyboard order).
|
|
*
|
|
* Each violation entry contains the rule id, a human message, and the
|
|
* outerHTML of the offending node (truncated) for fast debugging.
|
|
*
|
|
* The helper throws when any violation is found, so test cases can do:
|
|
*
|
|
* await expectNoA11yViolations(<ErrorPage status={404} />);
|
|
*
|
|
* If you need to inspect violations directly, use `findA11yViolations`.
|
|
*/
|
|
|
|
import type { ReactElement } from "react";
|
|
import { render, type RenderOptions, type RenderResult } from "@testing-library/react";
|
|
|
|
export interface A11yViolation {
|
|
rule: string;
|
|
message: string;
|
|
html: string;
|
|
}
|
|
|
|
const INPUT_TYPES_WITHOUT_LABEL_REQUIREMENT = new Set(["hidden", "submit", "reset", "button", "image"]);
|
|
const INTERACTIVE_ROLES = new Set(["button", "link", "menuitem", "tab", "checkbox", "radio", "switch"]);
|
|
|
|
function truncateHtml(html: string, max = 160): string {
|
|
if (html.length <= max) return html;
|
|
return `${html.slice(0, max)}…`;
|
|
}
|
|
|
|
function hasAccessibleName(el: Element): boolean {
|
|
const ariaLabel = el.getAttribute("aria-label");
|
|
if (ariaLabel && ariaLabel.trim().length > 0) return true;
|
|
const ariaLabelledBy = el.getAttribute("aria-labelledby");
|
|
if (ariaLabelledBy && ariaLabelledBy.trim().length > 0) return true;
|
|
const title = el.getAttribute("title");
|
|
if (title && title.trim().length > 0) return true;
|
|
const text = (el.textContent ?? "").trim();
|
|
if (text.length > 0) return true;
|
|
// images inside the control may supply alt text
|
|
const imgWithAlt = el.querySelector("img[alt]");
|
|
if (imgWithAlt) {
|
|
const alt = imgWithAlt.getAttribute("alt") ?? "";
|
|
if (alt.trim().length > 0) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function hasAssociatedLabel(input: Element): boolean {
|
|
// aria-* takes priority
|
|
const ariaLabel = input.getAttribute("aria-label");
|
|
if (ariaLabel && ariaLabel.trim().length > 0) return true;
|
|
const ariaLabelledBy = input.getAttribute("aria-labelledby");
|
|
if (ariaLabelledBy && ariaLabelledBy.trim().length > 0) return true;
|
|
const title = input.getAttribute("title");
|
|
if (title && title.trim().length > 0) return true;
|
|
|
|
const id = input.getAttribute("id");
|
|
if (id) {
|
|
const root = input.ownerDocument ?? document;
|
|
const explicitLabel = root.querySelector(`label[for="${CSS.escape(id)}"]`);
|
|
if (explicitLabel && (explicitLabel.textContent ?? "").trim().length > 0) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// wrapped <label><input ... /></label>
|
|
let parent: Element | null = input.parentElement;
|
|
while (parent) {
|
|
if (parent.tagName === "LABEL") {
|
|
if ((parent.textContent ?? "").trim().length > 0) return true;
|
|
break;
|
|
}
|
|
parent = parent.parentElement;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export function findA11yViolations(container: HTMLElement): A11yViolation[] {
|
|
const violations: A11yViolation[] = [];
|
|
|
|
// 1. <img> without alt
|
|
for (const img of Array.from(container.querySelectorAll("img"))) {
|
|
if (!img.hasAttribute("alt")) {
|
|
violations.push({
|
|
rule: "img-alt",
|
|
message: "<img> is missing an alt attribute (use alt=\"\" for decorative images).",
|
|
html: truncateHtml(img.outerHTML),
|
|
});
|
|
}
|
|
}
|
|
|
|
// 2. Interactive controls (button + role=button) without accessible name
|
|
const buttons = Array.from(container.querySelectorAll("button"));
|
|
for (const btn of buttons) {
|
|
if (btn.getAttribute("aria-hidden") === "true") continue;
|
|
if (!hasAccessibleName(btn)) {
|
|
violations.push({
|
|
rule: "button-name",
|
|
message: "<button> has no accessible name (children text, aria-label, aria-labelledby, or title).",
|
|
html: truncateHtml(btn.outerHTML),
|
|
});
|
|
}
|
|
}
|
|
|
|
const roleControls = Array.from(container.querySelectorAll("[role]"));
|
|
for (const el of roleControls) {
|
|
const role = el.getAttribute("role") ?? "";
|
|
if (!INTERACTIVE_ROLES.has(role)) continue;
|
|
if (el.tagName === "BUTTON" || el.tagName === "A") continue; // covered elsewhere
|
|
if (el.getAttribute("aria-hidden") === "true") continue;
|
|
if (!hasAccessibleName(el)) {
|
|
violations.push({
|
|
rule: "role-name",
|
|
message: `Element with role="${role}" has no accessible name.`,
|
|
html: truncateHtml(el.outerHTML),
|
|
});
|
|
}
|
|
}
|
|
|
|
// 3. <a href> without name
|
|
for (const link of Array.from(container.querySelectorAll("a"))) {
|
|
if (!link.hasAttribute("href")) continue;
|
|
if (link.getAttribute("aria-hidden") === "true") continue;
|
|
if (!hasAccessibleName(link)) {
|
|
violations.push({
|
|
rule: "link-name",
|
|
message: "<a> with href has no discernible text or aria-label.",
|
|
html: truncateHtml(link.outerHTML),
|
|
});
|
|
}
|
|
}
|
|
|
|
// 4. form fields without label
|
|
const inputs = Array.from(container.querySelectorAll("input, select, textarea"));
|
|
for (const field of inputs) {
|
|
const tag = field.tagName.toLowerCase();
|
|
if (tag === "input") {
|
|
const type = (field.getAttribute("type") ?? "text").toLowerCase();
|
|
if (INPUT_TYPES_WITHOUT_LABEL_REQUIREMENT.has(type)) continue;
|
|
}
|
|
if (field.getAttribute("aria-hidden") === "true") continue;
|
|
if (!hasAssociatedLabel(field)) {
|
|
violations.push({
|
|
rule: "label",
|
|
message: `<${tag}> has no associated <label>, aria-label, or aria-labelledby.`,
|
|
html: truncateHtml(field.outerHTML),
|
|
});
|
|
}
|
|
}
|
|
|
|
// 5. positive tabindex
|
|
for (const el of Array.from(container.querySelectorAll("[tabindex]"))) {
|
|
const raw = el.getAttribute("tabindex");
|
|
const value = Number(raw);
|
|
if (Number.isFinite(value) && value > 0) {
|
|
violations.push({
|
|
rule: "tabindex",
|
|
message: `Element has positive tabindex="${raw}" which breaks natural keyboard order.`,
|
|
html: truncateHtml(el.outerHTML),
|
|
});
|
|
}
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
function formatViolations(violations: A11yViolation[]): string {
|
|
return violations
|
|
.map((v, idx) => ` ${idx + 1}. [${v.rule}] ${v.message}\n ${v.html}`)
|
|
.join("\n");
|
|
}
|
|
|
|
/**
|
|
* Render `ui`, then assert there are no static accessibility violations.
|
|
* Throws an Error listing every violation if any are found.
|
|
*/
|
|
export async function expectNoA11yViolations(ui: ReactElement, options?: RenderOptions): Promise<RenderResult> {
|
|
const result = render(ui, options);
|
|
const violations = findA11yViolations(result.container);
|
|
if (violations.length > 0) {
|
|
throw new Error(`Found ${violations.length} accessibility violation(s):\n${formatViolations(violations)}`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Run the same checks against an already-rendered container. Useful when a
|
|
* test has set up the DOM manually or needs to assert after an interaction.
|
|
*/
|
|
export function assertNoA11yViolations(container: HTMLElement): void {
|
|
const violations = findA11yViolations(container);
|
|
if (violations.length > 0) {
|
|
throw new Error(`Found ${violations.length} accessibility violation(s):\n${formatViolations(violations)}`);
|
|
}
|
|
}
|