/** * 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 { 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 { 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 { 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 { 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 { // 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 { 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"); } }