Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/test/a11y.tsx
T

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)}`);
}
}