p19-bug-fixes
This commit is contained in:
271
e2e/tests/fixtures/accessibility.ts
vendored
Normal file
271
e2e/tests/fixtures/accessibility.ts
vendored
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user