mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
- 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
161 lines
4.8 KiB
TypeScript
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,
|
|
};
|
|
}
|