ARIA-метки и цветовой контраст
Updated Jul 2026
Лучшие практики ARIA
ARIA (Accessible Rich Internet Applications) дополняет семантику HTML. Первое правило ARIA: если можно использовать нативный HTML-элемент, не используйте ARIA. Нативные элементы имеют встроенную обработку клавиатуры, управление фокусом и семантику для экранных чтецов.
<!-- BAD: div with ARIA role mimicking a button -->
<div role="button" tabindex="0" aria-label="Submit" onclick="submit()">
Submit
</div>
<!-- Problems: no Enter/Space handling, no form submission, no disabled state -->
<!-- GOOD: native button element -- keyboard, screen reader, and click all work -->
<button type="submit">Submit</button>
<!-- WHEN ARIA IS NECESSARY: custom components with no native equivalent -->
<div role="tablist" aria-label="Product details">
<button role="tab" aria-selected="true" aria-controls="panel-desc" id="tab-desc">
Description
</button>
<button role="tab" aria-selected="false" aria-controls="panel-specs" id="tab-specs">
Specifications
</button>
</div>
<div role="tabpanel" id="panel-desc" aria-labelledby="tab-desc">
<!-- Description content -->
</div>
<div role="tabpanel" id="panel-specs" aria-labelledby="tab-specs" hidden>
<!-- Specs content -->
</div>
Автоматическое тестирование ARIA
Тестирование качества alt-текста
// tests/accessibility/aria-validation.spec.ts
import { test, expect } from '@playwright/test';
test('all images have alt text', async ({ page }) => {
await page.goto('/products');
const images = await page.locator('img').all();
const violations: string[] = [];
for (const img of images) {
const alt = await img.getAttribute('alt');
const src = await img.getAttribute('src');
const role = await img.getAttribute('role');
// Decorative images should have alt="" and role="presentation"
// Content images must have descriptive alt text
if (role === 'presentation' || role === 'none') {
// Decorative: alt must be empty string (not missing)
if (alt !== '') {
violations.push(`Decorative image ${src} should have alt="", got alt="${alt}"`);
}
} else {
// Content image: must have meaningful alt
if (!alt || alt.trim().length === 0) {
violations.push(`Image ${src} has no alt text`);
} else if (alt.match(/^(image|photo|picture|img|icon)\b/i)) {
violations.push(`Image ${src} has non-descriptive alt: "${alt}"`);
} else if (src && alt === src.split('/').pop()) {
violations.push(`Image ${src} uses filename as alt: "${alt}"`);
}
}
}
expect(violations).toEqual([]);
});
Тестирование индикаторов фокуса
test('interactive elements have visible focus indicators', async ({ page }) => {
await page.goto('/');
// Tab through all focusable elements
const focusableSelector = 'a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])';
const focusableCount = await page.locator(focusableSelector).count();
const violations: string[] = [];
for (let i = 0; i < Math.min(focusableCount, 50); i++) {
await page.keyboard.press('Tab');
// Get the currently focused element
const focused = page.locator(':focus');
const isVisible = await focused.isVisible();
if (isVisible) {
// Check that focus indicator is visible
const outlineStyle = await focused.evaluate((el) => {
const styles = window.getComputedStyle(el);
return {
outline: styles.outline,
outlineWidth: styles.outlineWidth,
outlineColor: styles.outlineColor,
boxShadow: styles.boxShadow,
border: styles.border,
tag: el.tagName,
text: el.textContent?.trim().substring(0, 30),
};
});
// Focus must be visible via outline, box-shadow, or border change
const hasVisibleFocus =
outlineStyle.outlineWidth !== '0px' ||
outlineStyle.boxShadow !== 'none' ||
outlineStyle.outline !== 'none';
if (!hasVisibleFocus) {
violations.push(
`${outlineStyle.tag} "${outlineStyle.text}" has no visible focus indicator`
);
}
}
}
expect(violations).toEqual([]);
});
Проверка цветового контраста
WCAG 2.1 AA требует:
- 4.5:1 коэффициент контраста для обычного текста (меньше 18pt или 14pt bold)
- 3:1 коэффициент контраста для крупного текста (18pt+ или 14pt+ bold)
- 3:1 коэффициент контраста для UI-компонентов и графических объектов
Автоматическое тестирование контраста
// tests/accessibility/color-contrast.spec.ts
import { test, expect } from '@playwright/test';
test('text elements meet WCAG AA contrast ratios', async ({ page }) => {
await page.goto('/');
const violations = await page.evaluate(() => {
const results: string[] = [];
// Get relative luminance of a color
function luminance(r: number, g: number, b: number): number {
const [rs, gs, bs] = [r, g, b].map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
}
// Calculate contrast ratio between two colors
function contrastRatio(l1: number, l2: number): number {
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// Parse color string to RGB
function parseColor(color: string): [number, number, number] | null {
const match = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (match) return [parseInt(match[1]), parseInt(match[2]), parseInt(match[3])];
return null;
}
// Check all text elements
const textElements = document.querySelectorAll(
'p, span, a, h1, h2, h3, h4, h5, h6, li, td, th, label, button'
);
textElements.forEach(el => {
const styles = window.getComputedStyle(el);
const fg = parseColor(styles.color);
const bg = parseColor(styles.backgroundColor);
if (fg && bg && styles.display !== 'none' && styles.visibility !== 'hidden') {
const fgLum = luminance(...fg);
const bgLum = luminance(...bg);
const ratio = contrastRatio(fgLum, bgLum);
const fontSize = parseFloat(styles.fontSize);
const isBold = parseInt(styles.fontWeight) >= 700;
const isLargeText = fontSize >= 24 || (fontSize >= 18.66 && isBold);
const requiredRatio = isLargeText ? 3.0 : 4.5;
if (ratio < requiredRatio) {
const text = el.textContent?.trim().substring(0, 40) || '';
results.push(
`"${text}" has contrast ${ratio.toFixed(2)}:1 ` +
`(needs ${requiredRatio}:1) -- ` +
`fg: rgb(${fg.join(',')}) bg: rgb(${bg.join(',')})`
);
}
}
});
return results;
});
if (violations.length > 0) {
console.error('Color contrast violations:');
violations.forEach(v => console.error(` - ${v}`));
}
expect(violations).toEqual([]);
});
Распространённые ошибки ARIA
| Ошибка | Почему это неправильно | Правильный подход |
|---|---|---|
<div role="button"> |
Отсутствует обработка клавиатуры | Используйте <button> |
aria-label на <div> без роли |
aria-label не имеет эффекта на типовых элементах | Добавьте роль или используйте семантический элемент |
aria-hidden="true" на фокусируемом элементе |
Удаляет из дерева доступности, но остаётся фокусируемым | Также добавьте tabindex="-1" |
Дублирование целей aria-labelledby |
Запутанный вывод экранного чтеца | Каждая цель метки должна быть уникальной |
role="presentation" на интерактивном элементе |
Удаляет семантику с кликабельного элемента | Никогда не подавляйте семантику на интерактивных элементах |
| Использование ARIA для исправления сломанной HTML-структуры | ARIA компенсирует, но не исправляет проблемы по-настоящему | Сначала исправьте HTML-структуру |
Тестирование этих ошибок можно автоматизировать:
test('no ARIA anti-patterns', async ({ page }) => {
await page.goto('/');
const antiPatterns = await page.evaluate(() => {
const issues: string[] = [];
// Check for divs with role="button" (should be <button>)
document.querySelectorAll('[role="button"]:not(button)').forEach(el => {
issues.push(`Non-button element with role="button": ${el.outerHTML.substring(0, 100)}`);
});
// Check for aria-hidden on focusable elements
document.querySelectorAll('[aria-hidden="true"]').forEach(el => {
const tabindex = el.getAttribute('tabindex');
if (tabindex !== '-1' && (el as HTMLElement).tabIndex >= 0) {
issues.push(`Focusable element with aria-hidden: ${el.outerHTML.substring(0, 100)}`);
}
});
return issues;
});
expect(antiPatterns).toEqual([]);
});
Тестирование ARIA и цветового контраста составляет основу автоматических проверок доступности. Оно быстро выполняется, выявляет реальные проблемы и требует минимальной настройки. Каждый проект должен иметь эти тесты с первого дня.