Track HiveGrid Cloud planning reference
This commit is contained in:
1 parent
b3d5ea6764
commit
e341c5938a
5784 files changed
+1104929
-3
No files matched your search
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 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)}`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user