5.5 KiB
5.5 KiB
Color Contrast Template
Tests contrast ratios, color-blind safe palettes, and focus indicator visibility.
Prerequisites
- App running at
{{baseUrl}} - axe-playwright installed:
npm i -D @axe-core/playwright - Page under test:
{{baseUrl}}/{{pagePath}}
TypeScript
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test.describe('Color Contrast', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{pagePath}}');
});
// Happy path: no color contrast violations (axe)
test('has no color contrast violations', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.withRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
// Happy path: body text contrast ratio ≥ 4.5:1
test('body text meets WCAG AA contrast ratio', async ({ page }) => {
const ratio = await page.evaluate(() => {
const el = document.querySelector('p, main, [class*="body"]') as HTMLElement;
if (!el) return null;
const style = getComputedStyle(el);
// Simplified check — use axe for full verification
return style.color !== 'rgba(0, 0, 0, 0)' ? style.color : null;
});
expect(ratio).toBeTruthy();
});
// Happy path: large text contrast ratio ≥ 3:1
test('headings have sufficient contrast', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.include('h1, h2, h3, h4, h5, h6')
.analyze();
expect(results.violations).toEqual([]);
});
// Happy path: focus indicator meets contrast requirement
test('focus indicator is visible and meets contrast', async ({ page }) => {
await page.getByRole('button').first().focus();
const outline = await page.getByRole('button').first().evaluate(el => {
const s = getComputedStyle(el, ':focus');
return {
outlineWidth: parseFloat(s.outlineWidth),
outlineColor: s.outlineColor,
outlineStyle: s.outlineStyle,
};
});
expect(outline.outlineWidth).toBeGreaterThanOrEqual(2);
expect(outline.outlineColor).not.toBe('rgba(0, 0, 0, 0)');
});
// Happy path: error text contrast
test('error messages have sufficient contrast', async ({ page }) => {
await page.goto('{{baseUrl}}/{{formPath}}');
await page.getByRole('button', { name: /submit/i }).click();
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.include('[class*="error"], [role="alert"]')
.analyze();
expect(results.violations).toEqual([]);
});
// Happy path: no information conveyed by color alone
test('status badges use text or icon in addition to color', async ({ page }) => {
const badges = page.getByRole('status');
const count = await badges.count();
for (let i = 0; i < count; i++) {
const text = await badges.nth(i).textContent();
const ariaLabel = await badges.nth(i).getAttribute('aria-label');
expect(text?.trim() || ariaLabel).toBeTruthy();
}
});
// Edge case: full page axe scan for all WCAG 2.1 AA issues
test('full page passes WCAG 2.1 AA axe scan', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.exclude('{{knownExcludedSelector}}')
.analyze();
if (results.violations.length > 0) {
const messages = results.violations.map(v =>
`${v.id}: ${v.description} — ${v.nodes.map(n => n.target).join(', ')}`
).join('\n');
throw new Error(`Axe violations:\n${messages}`);
}
});
});
JavaScript
const { test, expect } = require('@playwright/test');
const AxeBuilder = require('@axe-core/playwright').default;
test.describe('Color Contrast', () => {
test.beforeEach(async ({ page }) => {
await page.goto('{{baseUrl}}/{{pagePath}}');
});
test('no color contrast violations', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withRules(['color-contrast'])
.analyze();
expect(results.violations).toEqual([]);
});
test('focus indicator is visible', async ({ page }) => {
await page.getByRole('button').first().focus();
const outlineWidth = await page.getByRole('button').first().evaluate(
el => parseFloat(getComputedStyle(el).outlineWidth)
);
expect(outlineWidth).toBeGreaterThanOrEqual(2);
});
test('status badges use text not just color', async ({ page }) => {
const badges = page.getByRole('status');
const count = await badges.count();
for (let i = 0; i < count; i++) {
const text = await badges.nth(i).textContent();
const label = await badges.nth(i).getAttribute('aria-label');
expect((text?.trim()) || label).toBeTruthy();
}
});
test('full page passes WCAG 2.1 AA', async ({ page }) => {
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
});
Variants
| Variant | Description |
|---|---|
| Contrast violations | axe color-contrast rule → no violations |
| Body text contrast | Text color non-transparent |
| Heading contrast | axe include h1-h6 → no violations |
| Focus indicator | outline-width ≥ 2px and non-transparent |
| Error text contrast | Error messages pass axe |
| Color-only info | Badges have text or aria-label |
| Full axe scan | WCAG 2.1 AA complete scan |