← Назад к вопросам

Как обеспечить корректную работу скрин-ридеров на сайте?

1.3 Junior🔥 81 комментариев
#Soft Skills и рабочие процессы

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI3 апр. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Accessibility (a11y) и поддержка скрин-ридеров

Скрин-ридеры - это критически важные инструменты для людей с нарушениями зрения. Обеспечение полной доступности - это не только этическое требование, но и часть современного веб-стандарта WCAG 2.1.

1. Семантический HTML

Правильное использование элементов

<!-- Неправильно: всё на div'ах -->
<div class="button" onclick="handleClick()">Отправить</div>
<div class="heading">Заголовок</div>
<div class="list">
  <div>Элемент 1</div>
  <div>Элемент 2</div>
</div>

<!-- Правильно: семантические элементы -->
<button onClick={handleClick}>Отправить</button>
<h1>Заголовок</h1>
<ul>
  <li>Элемент 1</li>
  <li>Элемент 2</li>
</ul>

<nav>           {/* Навигация */}
<main>          {/* Главное содержимое */}
<article>       {/* Статья */}
<section>       {/* Раздел */}
<aside>         {/* Боковая панель */}
<footer>        {/* Подвал */}

2. ARIA атрибуты

// aria-label - описание для элементов без текста
<button aria-label="Закрыть модальное окно">
  ✕
</button>

// aria-describedby - расширенное описание
<input type="password" aria-describedby="pwd-hint" />
<p id="pwd-hint">Минимум 8 символов, включая буквы и цифры</p>

// aria-live - динамические обновления контента
<div aria-live="polite" aria-atomic="true">
  {message} {/* При изменении скрин-ридер объявит новое значение */}
</div>

// aria-hidden - скрыть от скрин-ридера
<span aria-hidden="true"></span> {/* Декоративный элемент */}

// role - определить роль элемента
<div role="alert">Критическая ошибка!</div>

// aria-expanded/aria-controls
<button 
  aria-expanded={isOpen} 
  aria-controls="menu"
>
  Меню
</button>
<ul id="menu" hidden={!isOpen}>
  <li>Опция 1</li>
</ul>

3. Компонент доступного Button

interface AccessibleButtonProps {
  children: React.ReactNode;
  onClick: () => void;
  disabled?: boolean;
  ariaLabel?: string;
  ariaDescribedBy?: string;
}

export function AccessibleButton({
  children,
  onClick,
  disabled = false,
  ariaLabel,
  ariaDescribedBy,
}: AccessibleButtonProps) {
  return (
    <button
      onClick={onClick}
      disabled={disabled}
      aria-label={ariaLabel}
      aria-describedby={ariaDescribedBy}
      className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
    >
      {children}
    </button>
  );
}

4. Навигация с клавиатуры

export function AccessibleMenu() {
  const [focusedIndex, setFocusedIndex] = useState(0);
  const items = ['Главная', 'О нас', 'Контакты'];
  const refs = useRef<HTMLButtonElement[]>([]);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      const nextIndex = (focusedIndex + 1) % items.length;
      setFocusedIndex(nextIndex);
      refs.current[nextIndex]?.focus();
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      const prevIndex = focusedIndex === 0 ? items.length - 1 : focusedIndex - 1;
      setFocusedIndex(prevIndex);
      refs.current[prevIndex]?.focus();
    } else if (e.key === 'Home') {
      e.preventDefault();
      setFocusedIndex(0);
      refs.current[0]?.focus();
    }
  };

  return (
    <ul
      role="menubar"
      onKeyDown={handleKeyDown}
      className="flex gap-4"
    >
      {items.map((item, index) => (
        <li key={item} role="none">
          <button
            ref={(el) => { if (el) refs.current[index] = el; }}
            role="menuitem"
            tabIndex={focusedIndex === index ? 0 : -1}
            className="px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
          >
            {item}
          </button>
        </li>
      ))}
    </ul>
  );
}

5. Форма с полной доступностью

export function AccessibleForm() {
  const [email, setEmail] = useState('');
  const [emailError, setEmailError] = useState('');

  const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    setEmail(value);
    
    if (!value.includes('@')) {
      setEmailError('Email должен содержать @');
    } else {
      setEmailError('');
    }
  };

  return (
    <form className="space-y-4">
      <div className="flex flex-col">
        <label htmlFor="email" className="font-medium mb-2">
          Email
          <span aria-label="обязательное поле" className="text-red-600">*</span>
        </label>
        <input
          id="email"
          type="email"
          value={email}
          onChange={handleEmailChange}
          aria-required="true"
          aria-invalid={!!emailError}
          aria-describedby={emailError ? 'email-error' : undefined}
          className="px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
          required
        />
        {emailError && (
          <p id="email-error" className="text-red-600 text-sm mt-1" role="alert">
            {emailError}
          </p>
        )}
      </div>

      <button
        type="submit"
        aria-label="Отправить форму"
        className="px-4 py-2 bg-blue-600 text-white rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
      >
        Отправить
      </button>
    </form>
  );
}

6. Изображения и альтернативный текст

// Неправильно
<img src="photo.jpg" />

// Правильно
<img 
  src="photo.jpg" 
  alt="Портрет человека в офисе, сидящего за компьютером" 
/>

// Для декоративных изображений
<img src="divider.png" alt="" aria-hidden="true" />

// Для сложных изображений (графики, диаграммы)
<figure>
  <img src="chart.png" alt="Диаграмма продаж по месяцам" />
  <figcaption>Продажи выросли на 40% в марте</figcaption>
</figure>

7. Цвет и контрастность

/* Минимальный контраст 4.5:1 для текста */
.text-dark {
  color: #1f2937; /* Почти черный */
  background: #ffffff; /* Белый */
  /* Контраст: 16.43:1 - отлично */
}

/* Не полагайся только на цвет */
.error-field {
  border: 2px solid #dc2626; /* Красный */
  border-left: 4px solid #dc2626; /* Дополнительный индикатор */
  background-color: #fee2e2;
}

.success {
  border: 2px solid #16a34a; /* Зеленый */
  /* + иконка галочки для дублирования информации */
}

8. Использование heading иерархии

// Правильно
export function BlogPost() {
  return (
    <article>
      <h1>Главный заголовок статьи</h1> {/* Одна h1 на странице */}
      
      <section>
        <h2>Раздел 1</h2>
        <h3>Подраздел 1.1</h3>
        <p>Текст...</p>
      </section>
      
      <section>
        <h2>Раздел 2</h2>
        <h3>Подраздел 2.1</h3>
        <p>Текст...</p>
      </section>
    </article>
  );
}

// Неправильно - нарушена иерархия
<h1>Заголовок</h1>
<h3>Подраздел</h3> {/* Пропущена h2 */}

9. Тестирование доступности

# Установка инструментов
npm install --save-dev @testing-library/jest-dom @axe-core/react

# Тест с axe
import { axe, toHaveNoViolations } from 'jest-axe';

test('Button should have no accessibility violations', async () => {
  const { container } = render(<Button>Click me</Button>);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Чеклист доступности

  • Используйте семантический HTML
  • Все интерактивные элементы должны быть доступны с клавиатуры
  • Текст должен иметь контраст минимум 4.5:1
  • Все изображения должны иметь alt текст
  • Иерархия heading правильная (h1 -> h2 -> h3)
  • Форма имеет правильные label'ы
  • Используйте aria-live для динамического контента
  • Тестируйте с реальными скрин-ридерами (NVDA, JAWS, VoiceOver)

Доступность - это не фича, это основание хорошего веб-приложения.