Files
clawfort/e2e/tests/fixtures/accessibility.ts
2026-02-13 16:57:45 -05:00

272 lines
6.7 KiB
TypeScript

/**
* Accessibility helpers for WCAG 2.2 AA testing
*/
import type { Locator, Page } from "@playwright/test";
/**
* WCAG 2.2 AA contrast ratio requirements
*/
export const WCAG_CONTRAST = {
normalText: 4.5,
largeText: 3,
uiComponents: 3,
} as const;
/**
* Check if an element has visible focus indicator
*/
export async function hasFocusVisible(
page: Page,
locator: Locator,
): Promise<boolean> {
return page.evaluate(
(element: Element) => {
const style = window.getComputedStyle(element);
const outline = style.outlineWidth;
const boxShadow = style.boxShadow;
// Check for outline or box-shadow focus indicator
return (
(outline && outline !== "0px" && outline !== "none") ||
(boxShadow && boxShadow !== "none")
);
},
(await locator.elementHandle()) as Element,
);
}
/**
* Get computed color values for an element
*/
export async function getComputedColors(
page: Page,
locator: Locator,
): Promise<{
color: string;
backgroundColor: string;
}> {
return page.evaluate(
(element: Element) => {
const style = window.getComputedStyle(element);
return {
color: style.color,
backgroundColor: style.backgroundColor,
};
},
(await locator.elementHandle()) as Element,
);
}
/**
* Calculate relative luminance of a color
* https://www.w3.org/TR/WCAG20/#relativeluminancedef
*/
export function getLuminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map((val) => {
const sRGB = val / 255;
return sRGB <= 0.03928 ? sRGB / 12.92 : ((sRGB + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
/**
* Parse RGB color string
*/
export function parseRGB(
color: string,
): { r: number; g: number; b: number } | null {
const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/);
if (!match) return null;
return {
r: parseInt(match[1], 10),
g: parseInt(match[2], 10),
b: parseInt(match[3], 10),
};
}
/**
* Parse RGBA color string
*/
export function parseRGBA(
color: string,
): { r: number; g: number; b: number; a: number } | null {
const match = color.match(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)/);
if (!match) return null;
return {
r: parseInt(match[1], 10),
g: parseInt(match[2], 10),
b: parseInt(match[3], 10),
a: parseFloat(match[4]),
};
}
/**
* Calculate contrast ratio between two luminances
* https://www.w3.org/TR/WCAG20/#contrast-ratiodef
*/
export function getContrastRatio(lum1: number, lum2: number): number {
const lighter = Math.max(lum1, lum2);
const darker = Math.min(lum1, lum2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Check contrast ratio between two colors
*/
export function checkContrast(color1: string, color2: string): number | null {
const rgb1 = parseRGB(color1) || parseRGBA(color1);
const rgb2 = parseRGB(color2) || parseRGBA(color2);
if (!rgb1 || !rgb2) return null;
const lum1 = getLuminance(rgb1.r, rgb1.g, rgb1.b);
const lum2 = getLuminance(rgb2.r, rgb2.g, rgb2.b);
return getContrastRatio(lum1, lum2);
}
/**
* Assert element meets WCAG AA contrast requirements
*/
export async function assertContrast(
page: Page,
locator: Locator,
minRatio: number = WCAG_CONTRAST.normalText,
): Promise<void> {
const colors = await getComputedColors(page, locator);
const ratio = checkContrast(colors.color, colors.backgroundColor);
if (ratio === null) {
throw new Error("Could not calculate contrast ratio");
}
if (ratio < minRatio) {
throw new Error(
`Contrast ratio ${ratio.toFixed(2)} is below required ${minRatio} ` +
`(color: ${colors.color}, background: ${colors.backgroundColor})`,
);
}
}
/**
* Check if element has accessible name
*/
export async function hasAccessibleName(locator: Locator): Promise<boolean> {
const name = await locator.getAttribute("aria-label");
const labelledBy = await locator.getAttribute("aria-labelledby");
const title = await locator.getAttribute("title");
// Check for text content if it's a button or link
const tagName = await locator.evaluate((el) => el.tagName.toLowerCase());
let textContent = "";
if (tagName === "button" || tagName === "a") {
textContent = (await locator.textContent()) || "";
}
return !!(name || labelledBy || title || textContent.trim());
}
/**
* Get accessible name for an element
*/
export async function getAccessibleName(
page: Page,
locator: Locator,
): Promise<string | null> {
return page.evaluate(
(element: Element) => {
// Check aria-label
const ariaLabel = element.getAttribute("aria-label");
if (ariaLabel) return ariaLabel;
// Check aria-labelledby
const labelledBy = element.getAttribute("aria-labelledby");
if (labelledBy) {
const labelElement = document.getElementById(labelledBy);
if (labelElement) return labelElement.textContent;
}
// Check title attribute
const title = element.getAttribute("title");
if (title) return title;
// Check text content for interactive elements
const tagName = element.tagName.toLowerCase();
if (tagName === "button" || tagName === "a") {
return element.textContent?.trim() || null;
}
return null;
},
(await locator.elementHandle()) as Element,
);
}
/**
* Test keyboard navigation through a set of elements
*/
export async function testKeyboardNavigation(
page: Page,
selectors: string[],
): Promise<void> {
// Focus first element
await page.focus(selectors[0]);
for (let i = 0; i < selectors.length; i++) {
const activeElement = await page.evaluate(
() =>
document.activeElement?.getAttribute("data-testid") ||
document.activeElement?.getAttribute("aria-label") ||
document.activeElement?.textContent?.trim() ||
document.activeElement?.tagName,
);
// Check focus is visible
const hasFocus = await page.evaluate(() => {
const active = document.activeElement;
if (!active || active === document.body) return false;
const style = window.getComputedStyle(active);
return (
(style.outlineWidth && style.outlineWidth !== "0px") ||
(style.boxShadow && style.boxShadow !== "none")
);
});
if (!hasFocus) {
throw new Error(`Focus not visible on element: ${activeElement}`);
}
// Press Tab to move to next element
if (i < selectors.length - 1) {
await page.keyboard.press("Tab");
}
}
}
/**
* Assert focus is contained within a modal
*/
export async function assertFocusContained(
page: Page,
modalSelector: string,
): Promise<void> {
const isContained = await page.evaluate((selector: string) => {
const modal = document.querySelector(selector);
if (!modal) return false;
const activeElement = document.activeElement;
if (!activeElement) return false;
return modal.contains(activeElement);
}, modalSelector);
if (!isContained) {
throw new Error("Focus is not contained within modal");
}
}