Files
OmniRoute/src/shared/utils/a11yAudit.ts
diegosouzapw 0279341221 feat(phase4): focus indicators, session management, k6 load tests, remove @ts-check
- U-3/U-4: Global focus-visible indicators + CSS focus-ring utility
- C-6: Removed @ts-check from 18 .ts files (redundant in TypeScript)
- T-5: k6 load test script (tests/load/proxy-load.js)
- P-3: SessionInfoCard component in Security settings tab
2026-02-18 14:51:15 -03:00

161 lines
4.8 KiB
TypeScript

/**
* a11y Audit — Basic WCAG Accessibility Checker
*
* Simple HTML string audit for common accessibility violations.
* Uses regex-based detection (lightweight, no DOM parser required).
*
* @module shared/utils/a11yAudit
*/
/** WCAG rule identifiers */
export const WCAG_RULES = {
IMAGE_ALT: "image-alt",
ARIA_LABEL: "aria-label",
DIALOG_ROLE: "dialog-role",
COLOR_CONTRAST: "color-contrast",
FOCUS_TRAP: "focus-trap",
HEADING_ORDER: "heading-order",
};
/**
* @typedef {Object} Violation
* @property {string} id - Rule identifier from WCAG_RULES
* @property {string} description - Human-readable description
* @property {string} impact - "critical" | "serious" | "moderate" | "minor"
* @property {string} help - Remediation guidance
* @property {Array<{html: string}>} nodes - Offending elements
*/
/**
* Audit an HTML string for common accessibility violations.
*
* @param {string} html - HTML string to audit
* @returns {Violation[]} List of violations found
*/
export function auditHTML(html) {
const violations = [];
// Check images without alt text
const imgMatches = html.match(/<img\b[^>]*>/gi) || [];
for (const img of imgMatches) {
if (!/\balt\s*=/i.test(img)) {
violations.push({
id: WCAG_RULES.IMAGE_ALT,
description: "Images must have alternate text",
impact: "critical",
help: "Add an alt attribute to the <img> element",
nodes: [{ html: img }],
});
}
}
// Check dialogs/modals without role
const modalMatches = html.match(/<div\b[^>]*class="[^"]*modal[^"]*"[^>]*>/gi) || [];
for (const modal of modalMatches) {
if (!/\brole\s*=/i.test(modal)) {
violations.push({
id: WCAG_RULES.DIALOG_ROLE,
description: 'Dialogs must have role="dialog"',
impact: "serious",
help: 'Add role="dialog" and aria-modal="true" to the modal container',
nodes: [{ html: modal }],
});
}
}
return violations;
}
/**
* Parse a hex color string to RGB components.
* @param {string} hex - Color in #RGB, #RRGGBB, or #RRGGBBAA format
* @returns {{ r: number, g: number, b: number }|null}
*/
function parseHexColor(hex) {
if (!hex || typeof hex !== "string") return null;
const clean = hex.replace(/^#/, "");
let r, g, b;
if (clean.length === 3) {
r = parseInt(clean[0] + clean[0], 16);
g = parseInt(clean[1] + clean[1], 16);
b = parseInt(clean[2] + clean[2], 16);
} else if (clean.length === 6 || clean.length === 8) {
r = parseInt(clean.slice(0, 2), 16);
g = parseInt(clean.slice(2, 4), 16);
b = parseInt(clean.slice(4, 6), 16);
} else {
return null;
}
return { r, g, b };
}
/**
* Compute relative luminance per WCAG 2.x specification.
* @param {{ r: number, g: number, b: number }} rgb
* @returns {number} Relative luminance (0..1)
*/
function relativeLuminance({ r, g, b }) {
const [sR, sG, sB] = [r, g, b].map((c) => {
const v = c / 255;
return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
});
return 0.2126 * sR + 0.7152 * sG + 0.0722 * sB;
}
/**
* Get the contrast ratio between two hex colors.
* @param {string} fgHex - Foreground color (#RRGGBB)
* @param {string} bgHex - Background color (#RRGGBB)
* @returns {number} Contrast ratio (1..21)
*/
export function getContrastRatio(fgHex, bgHex) {
const fg = parseHexColor(fgHex);
const bg = parseHexColor(bgHex);
if (!fg || !bg) return 0;
const l1 = relativeLuminance(fg);
const l2 = relativeLuminance(bg);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Check WCAG AA contrast compliance between foreground and background colors.
*
* @param {string} fgHex - Foreground color (#RRGGBB)
* @param {string} bgHex - Background color (#RRGGBB)
* @param {{ largeText?: boolean }} [options={}] - Options
* @returns {{ ratio: number, aa: boolean, aaa: boolean }}
*/
export function checkContrast(fgHex, bgHex, options: any = {}) {
const ratio = getContrastRatio(fgHex, bgHex);
const minAA = options.largeText ? 3 : 4.5;
const minAAA = options.largeText ? 4.5 : 7;
return {
ratio: Math.round(ratio * 100) / 100,
aa: ratio >= minAA,
aaa: ratio >= minAAA,
};
}
/**
* Generate a summary report from a list of violations.
*
* @param {Violation[]} violations
* @returns {{ total: number, critical: number, serious: number, moderate: number, minor: number, passed: boolean }}
*/
export function generateReport(violations) {
return {
total: violations.length,
critical: violations.filter((v) => v.impact === "critical").length,
serious: violations.filter((v) => v.impact === "serious").length,
moderate: violations.filter((v) => v.impact === "moderate").length,
minor: violations.filter((v) => v.impact === "minor").length,
passed: violations.length === 0,
};
}