@birhaus/test-utils
Version:
BIRHAUS v3.0 Radical Minimalist Testing Framework - Glass morphism validators, generous spacing tests, and v3 component validation utilities
396 lines (392 loc) • 15.7 kB
JavaScript
var jestAxe = require('jest-axe');
// src/accessibility/AccessibilityValidator.ts
expect.extend(jestAxe.toHaveNoViolations);
var AccessibilityValidator = class {
constructor(options = {}) {
this.options = {
level: "AA",
includeColorContrast: true,
includeKeyboardNav: true,
includeFocusManagement: true,
includeAriaLabels: true,
includeSpanishLabels: true,
customRules: {},
interactiveElements: 'button, a, input, select, textarea, [role="button"], [role="link"], [tabindex]',
spanishElements: '[data-label-es], [aria-label*="es"], [lang="es"]',
...options
};
}
/**
* Run comprehensive accessibility validation
*/
async validateAll(container = document.body) {
const violations = [];
const axeViolations = await this.validateWithAxe(container);
violations.push(...axeViolations);
if (this.options.includeColorContrast) {
violations.push(...this.validateColorContrast(container));
}
if (this.options.includeKeyboardNav) {
violations.push(...await this.validateKeyboardNavigation(container));
}
if (this.options.includeFocusManagement) {
violations.push(...this.validateFocusManagement(container));
}
if (this.options.includeAriaLabels) {
violations.push(...this.validateAriaLabels(container));
}
if (this.options.includeSpanishLabels) {
violations.push(...this.validateSpanishLabels(container));
}
return violations;
}
/**
* Validate using axe-core accessibility engine
*/
async validateWithAxe(container) {
const violations = [];
try {
const results = await jestAxe.axe(container, {
rules: {
// Enable specific WCAG rules based on level
"color-contrast": { enabled: this.options.level !== "A" },
"color-contrast-enhanced": { enabled: this.options.level === "AAA" },
...this.options.customRules
}
});
results.violations.forEach((violation) => {
violation.nodes.forEach((node) => {
const element = document.querySelector(node.target[0]);
if (element) {
violations.push({
type: this.getViolationType(violation.id),
element,
severity: violation.impact || "moderate",
message: violation.description,
messageEs: this.translateViolation(violation.description),
wcagLevel: this.getWcagLevel(violation.tags),
wcagCriterion: violation.id,
suggestions: violation.nodes[0]?.failureSummary ? [violation.nodes[0].failureSummary] : [],
birhausPrinciple: 6
});
}
});
});
} catch (error) {
console.error("Axe accessibility test failed:", error);
}
return violations;
}
/**
* Validate color contrast ratios (WCAG AA+ requirement)
*/
validateColorContrast(container) {
const violations = [];
const elements = container.querySelectorAll("*");
elements.forEach((element) => {
const style = getComputedStyle(element);
const textColor = style.color;
const backgroundColor = style.backgroundColor;
if (textColor && backgroundColor && this.hasText(element)) {
const contrast = this.calculateContrastRatio(textColor, backgroundColor);
const requiredContrast = this.getRequiredContrast(element);
if (contrast < requiredContrast) {
violations.push({
type: "contrast",
element,
severity: contrast < requiredContrast * 0.7 ? "serious" : "moderate",
message: `Color contrast ratio ${contrast.toFixed(2)}:1 is below WCAG ${this.options.level} requirement of ${requiredContrast}:1`,
messageEs: `Contraste de color ${contrast.toFixed(2)}:1 est\xE1 por debajo del requisito WCAG ${this.options.level} de ${requiredContrast}:1`,
wcagLevel: this.options.level,
wcagCriterion: "color-contrast",
suggestions: [
"Increase color contrast between text and background",
"Use darker text colors or lighter background colors",
"Test with BIRHAUS financial theme for compliant colors",
"Consider using semantic color tokens from theme"
],
birhausPrinciple: 6
});
}
}
});
return violations;
}
/**
* Validate keyboard navigation (WCAG requirement)
*/
// eslint-disable-next-line max-lines-per-function -- Keyboard navigation validation requires comprehensive testing
async validateKeyboardNavigation(container) {
const violations = [];
const interactiveElements = container.querySelectorAll(this.options.interactiveElements);
for (const element of Array.from(interactiveElements)) {
try {
if (!this.isFocusable(element)) {
violations.push({
type: "keyboard",
element,
severity: "serious",
message: "Interactive element cannot be focused with keyboard",
messageEs: "Elemento interactivo no puede recibir foco con teclado",
wcagLevel: "A",
wcagCriterion: "keyboard-accessible",
suggestions: [
'Add tabindex="0" to make element focusable',
"Ensure element is not disabled when it should be interactive",
"Use semantic HTML elements (button, a, input) instead of divs",
"Add proper ARIA roles for custom interactive elements"
],
birhausPrinciple: 6
});
}
element.focus();
const focusedStyle = getComputedStyle(element);
const hasFocusIndicator = this.hasFocusIndicator(element, focusedStyle);
if (!hasFocusIndicator) {
violations.push({
type: "focus",
element,
severity: "moderate",
message: "Element lacks visible focus indicator",
messageEs: "Elemento carece de indicador visual de foco",
wcagLevel: "AA",
wcagCriterion: "focus-visible",
suggestions: [
"Add focus:ring-2 focus:ring-blue-500 classes",
"Use BIRHAUS focus styles from design tokens",
"Ensure focus outline is visible against background",
"Test focus indicators with high contrast mode"
],
birhausPrinciple: 6
});
}
} catch (error) {
console.warn("Keyboard navigation test failed for element:", element);
}
}
return violations;
}
/**
* Validate focus management
*/
validateFocusManagement(container) {
const violations = [];
const dialogs = container.querySelectorAll('[role="dialog"], [role="alertdialog"], [data-birhaus-component="dialog"]');
dialogs.forEach((dialog) => {
const focusableElements = this.getFocusableElements(dialog);
if (focusableElements.length === 0) {
violations.push({
type: "focus",
element: dialog,
severity: "serious",
message: "Dialog contains no focusable elements",
messageEs: "Di\xE1logo no contiene elementos que puedan recibir foco",
wcagLevel: "A",
wcagCriterion: "focus-management",
suggestions: [
"Add at least one focusable element (button, input, etc.)",
"Use BirhausDialog which includes proper focus management",
"Ensure close button is focusable",
'Add tabindex="-1" to dialog container if needed'
],
birhausPrinciple: 6
});
}
});
return violations;
}
/**
* Validate ARIA labels and roles
*/
validateAriaLabels(container) {
const violations = [];
const interactiveElements = container.querySelectorAll(this.options.interactiveElements);
interactiveElements.forEach((element) => {
const hasAccessibleName = this.hasAccessibleName(element);
if (!hasAccessibleName) {
violations.push({
type: "aria",
element,
severity: "serious",
message: "Interactive element lacks accessible name",
messageEs: "Elemento interactivo carece de nombre accesible",
wcagLevel: "A",
wcagCriterion: "name-role-value",
suggestions: [
"Add aria-label attribute with descriptive text",
"Add aria-labelledby pointing to a label element",
"Use semantic HTML with proper text content",
"For BIRHAUS components, use labelEs and labelEn props"
],
birhausPrinciple: 6
});
}
});
return violations;
}
/**
* Validate Spanish-first accessibility (BIRHAUS Principle #7)
*/
validateSpanishLabels(container) {
const violations = [];
const birhausComponents = container.querySelectorAll("[data-birhaus-component]");
birhausComponents.forEach((element) => {
const hasSpanishLabel = this.hasSpanishAccessibilityLabel(element);
if (!hasSpanishLabel) {
violations.push({
type: "spanish",
element,
severity: "moderate",
message: "BIRHAUS component lacks Spanish accessibility labels",
messageEs: "Componente BIRHAUS carece de etiquetas de accesibilidad en espa\xF1ol",
wcagLevel: "AA",
wcagCriterion: "language-identification",
suggestions: [
"Add labelEs prop for Spanish labels",
"Include aria-label in Spanish",
"Use data-label-es attribute",
'Set lang="es" for Spanish content sections'
],
birhausPrinciple: 7
});
}
});
return violations;
}
// Helper methods
getViolationType(ruleId) {
if (ruleId.includes("contrast")) return "contrast";
if (ruleId.includes("keyboard") || ruleId.includes("tabindex")) return "keyboard";
if (ruleId.includes("aria") || ruleId.includes("label")) return "aria";
if (ruleId.includes("focus")) return "focus";
return "structure";
}
getWcagLevel(tags) {
if (tags.includes("wcag2aaa")) return "AAA";
if (tags.includes("wcag2aa")) return "AA";
return "A";
}
translateViolation(description) {
const translations = {
"Elements must have sufficient color contrast": "Los elementos deben tener suficiente contraste de color",
"All interactive elements must have an accessible name": "Todos los elementos interactivos deben tener un nombre accesible",
"Elements must have keyboard access": "Los elementos deben tener acceso por teclado",
"Form elements must have labels": "Los elementos de formulario deben tener etiquetas"
};
return translations[description] || description;
}
hasText(element) {
const textContent = element.textContent?.trim();
return textContent !== null && textContent !== void 0 && textContent.length > 0;
}
calculateContrastRatio(color1, color2) {
const rgb1 = this.parseColor(color1);
const rgb2 = this.parseColor(color2);
const l1 = this.getRelativeLuminance(rgb1);
const l2 = this.getRelativeLuminance(rgb2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
parseColor(color) {
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (match) {
return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
}
return [0, 0, 0];
}
getRelativeLuminance([r, g, b]) {
const [rs, gs, bs] = [r, g, b].map((c) => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
getRequiredContrast(element) {
const style = getComputedStyle(element);
const fontSize = parseFloat(style.fontSize);
const fontWeight = style.fontWeight;
const isLargeText = fontSize >= 18 || fontSize >= 14 && (fontWeight === "bold" || parseInt(fontWeight) >= 700);
return this.options.level === "AAA" ? isLargeText ? 4.5 : 7 : isLargeText ? 3 : 4.5;
}
isFocusable(element) {
const tabIndex = element.getAttribute("tabindex");
const isDisabled = element.hasAttribute("disabled");
const tagName = element.tagName.toLowerCase();
if (isDisabled) return false;
if (tabIndex === "-1") return false;
if (tabIndex && parseInt(tabIndex) >= 0) return true;
const focusableTags = ["a", "button", "input", "select", "textarea"];
return focusableTags.includes(tagName);
}
hasFocusIndicator(element, style) {
return !!(style.outline !== "none" || style.outlineWidth !== "0px" || style.boxShadow !== "none" || style.border !== style.borderWidth);
}
getFocusableElements(container) {
const focusableSelectors = [
"button:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
"textarea:not([disabled])",
"a[href]",
'[tabindex]:not([tabindex="-1"])'
].join(", ");
return Array.from(container.querySelectorAll(focusableSelectors));
}
hasAccessibleName(element) {
const ariaLabel = element.getAttribute("aria-label");
const ariaLabelledBy = element.getAttribute("aria-labelledby");
const textContent = element.textContent?.trim();
return !!(ariaLabel || ariaLabelledBy || textContent);
}
hasSpanishAccessibilityLabel(element) {
const ariaLabel = element.getAttribute("aria-label");
const dataLabelEs = element.getAttribute("data-label-es");
const lang = element.getAttribute("lang");
const hasSpanishPatterns = ariaLabel && /[ñáéíóúü]|ción|dad|mente/i.test(ariaLabel);
return !!(dataLabelEs || hasSpanishPatterns || lang === "es");
}
};
async function expectAccessibilityCompliance(container, level = "AA") {
const validator = new AccessibilityValidator({ level });
const violations = await validator.validateAll(container);
const criticalViolations = violations.filter((v) => v.severity === "serious" || v.severity === "critical");
if (criticalViolations.length > 0) {
const errorMessages = criticalViolations.map((v) => `${v.type}: ${v.message}`).join("\n");
throw new Error(
`WCAG ${level} violations detected:
${errorMessages}
Total violations: ${violations.length} (${criticalViolations.length} critical/serious)`
);
}
}
async function expectSpanishAccessibility(container) {
const validator = new AccessibilityValidator({ includeSpanishLabels: true });
const violations = await validator.validateAll(container);
const spanishViolations = violations.filter((v) => v.type === "spanish");
if (spanishViolations.length > 0) {
const errorMessages = spanishViolations.map((v) => v.messageEs).join("\n");
throw new Error(
`Spanish accessibility violations detected:
${errorMessages}`
);
}
}
async function expectKeyboardAccessible(container) {
const validator = new AccessibilityValidator({ includeKeyboardNav: true });
const violations = await validator.validateKeyboardNavigation(container || document.body);
if (violations.length > 0) {
const errorMessages = violations.map((v) => v.message).join("\n");
throw new Error(
`Keyboard accessibility violations detected:
${errorMessages}`
);
}
}
exports.AccessibilityValidator = AccessibilityValidator;
exports.expectAccessibilityCompliance = expectAccessibilityCompliance;
exports.expectKeyboardAccessible = expectKeyboardAccessible;
exports.expectSpanishAccessibility = expectSpanishAccessibility;
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map
;