Modern QA2026ARIA-метки и цветовой контраст
Join

Course10 Visual & Accessibility Testing

Cutting-edge · Chapter 10

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 и цветового контраста составляет основу автоматических проверок доступности. Оно быстро выполняется, выявляет реальные проблемы и требует минимальной настройки. Каждый проект должен иметь эти тесты с первого дня.