272 lines
6.7 KiB
TypeScript
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");
|
|
}
|
|
}
|