← React/Доступность (a11y) в React#298 из 383← ПредыдущийСледующий →+25 XP
Полезно по теме:Гайд: React или VueПрактика: React setТермин: React HooksТема: React: хуки и экосистема

Доступность (a11y) в React

Что такое доступность и зачем это важно

Доступность (accessibility, a11y) — создание интерфейсов, которыми могут пользоваться люди с ограниченными возможностями: слабовидящие (скринридеры), люди с моторными нарушениями (только клавиатура), глухие (субтитры).

a11y — аббревиатура: первая буква "a", потом 11 букв, потом "y".

Почему это важно:

  • Правовые требования (ADA в США, EN 301 549 в Европе)
  • Увеличение аудитории (15% людей имеют инвалидность)
  • Улучшает UX для всех: клавиатурная навигация, понятные подписи
  • Влияет на SEO (скринридеры и поисковики читают похоже)
  • Семантический HTML в JSX

    В JSX нужно использовать правильные HTML-теги по смыслу:

    // Плохо: div-суп
    function BadExample() {
      return (
        <div onClick={handleClick}>Нажми меня</div>  // не кнопка!
      )
    }
    
    // Хорошо: семантика
    function GoodExample() {
      return (
        <button onClick={handleClick}>Нажми меня</button>
      )
    }
    
    // Плохо: нет структуры заголовков
    <div className="title">Главная</div>
    <div className="subtitle">О нас</div>
    
    // Хорошо: иерархия h1-h6
    <h1>Главная</h1>
    <h2>О нас</h2>

    ARIA атрибуты

    ARIA (Accessible Rich Internet Applications) — атрибуты для описания интерактивных элементов:

    // aria-label: текстовое описание для скринридера
    <button aria-label="Закрыть модальное окно">
      ✕
    </button>
    
    // aria-labelledby: ссылка на элемент-подпись
    <div role="dialog" aria-labelledby="dialog-title">
      <h2 id="dialog-title">Подтверждение</h2>
      <p>Удалить элемент?</p>
    </div>
    
    // aria-describedby: дополнительное описание
    <input
      id="email"
      aria-describedby="email-hint"
      type="email"
    />
    <p id="email-hint">Мы не будем передавать ваш email третьим лицам.</p>
    
    // aria-expanded: состояние раскрытия
    <button aria-expanded={isOpen} aria-controls="menu">
      Меню
    </button>
    
    // aria-live: живые регионы для уведомлений
    <div aria-live="polite" aria-atomic="true">
      {statusMessage}
    </div>
    
    // role: указывает роль элемента
    <div role="alert">Произошла ошибка</div>
    <div role="status">Данные сохранены</div>
    <nav role="navigation" aria-label="Главная навигация">...</nav>

    Управление фокусом

    import { useRef, useEffect } from 'react'
    
    function Modal({ isOpen, onClose, children }) {
      const firstFocusableRef = useRef(null)
      const closeButtonRef = useRef(null)
    
      // При открытии перемещаем фокус внутрь модала
      useEffect(() => {
        if (isOpen) {
          firstFocusableRef.current?.focus()
        }
      }, [isOpen])
    
      // Ловушка фокуса: Tab не уходит из модала
      function handleKeyDown(e) {
        if (e.key === 'Escape') onClose()
    
        if (e.key === 'Tab') {
          const focusable = modalRef.current.querySelectorAll(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
          )
          const first = focusable[0]
          const last = focusable[focusable.length - 1]
    
          if (e.shiftKey && document.activeElement === first) {
            last.focus()
            e.preventDefault()
          } else if (!e.shiftKey && document.activeElement === last) {
            first.focus()
            e.preventDefault()
          }
        }
      }
    
      if (!isOpen) return null
    
      return (
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby="modal-title"
          onKeyDown={handleKeyDown}
        >
          <h2 id="modal-title" ref={firstFocusableRef} tabIndex={-1}>
            Заголовок
          </h2>
          {children}
          <button ref={closeButtonRef} onClick={onClose}>Закрыть</button>
        </div>
      )
    }

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

    // Информационное изображение — нужен alt
    <img src="chart.png" alt="График роста продаж: +23% в Q4 2024" />
    
    // Декоративное изображение — пустой alt (скринридер пропускает)
    <img src="divider.png" alt="" role="presentation" />
    
    // Иконка в кнопке — alt на иконке или aria-label на кнопке
    <button aria-label="Удалить">
      <img src="trash.svg" alt="" />  {/* alt="" т.к. кнопка уже подписана */}
    </button>

    Формы: labels и ошибки

    function AccessibleForm() {
      const [errors, setErrors] = useState({})
    
      return (
        <form>
          <div>
            <label htmlFor="name">
              Имя <span aria-hidden="true">*</span>
              <span className="sr-only">(обязательное поле)</span>
            </label>
            <input
              id="name"
              name="name"
              required
              aria-required="true"
              aria-invalid={!!errors.name}
              aria-describedby={errors.name ? 'name-error' : undefined}
            />
            {errors.name && (
              <span id="name-error" role="alert">
                {errors.name}
              </span>
            )}
          </div>
        </form>
      )
    }

    Инструменты проверки

    # eslint-plugin-jsx-a11y — статический анализ в редакторе
    npm install eslint-plugin-jsx-a11y -D
    
    # .eslintrc
    { "plugins": ["jsx-a11y"], "extends": ["plugin:jsx-a11y/recommended"] }
    
    # axe-core — аудит в рантайме (для разработки)
    npm install @axe-core/react -D
    // Только в development режиме
    if (process.env.NODE_ENV !== 'production') {
      const axe = require('@axe-core/react')
      axe(React, ReactDOM, 1000)  // проверка каждую секунду
    }

    Примеры

    Инспектор доступности на ванильном JS: анализ дерева элементов на отсутствие alt, labels, нарушение иерархии заголовков и несемантические интерактивные элементы

    // Симулируем аудит доступности: проверяем дерево элементов
    // на типичные a11y нарушения.
    
    // --- Типы нарушений ---
    
    const VIOLATIONS = {
      MISSING_ALT: 'missing-alt',
      MISSING_LABEL: 'missing-label',
      MISSING_BUTTON_TEXT: 'missing-button-text',
      DIV_CLICK: 'div-with-click',
      HEADING_SKIP: 'heading-skip',
      FORM_NO_LABEL: 'form-no-label',
    }
    
    // --- Аудитор ---
    
    function createA11yAuditor() {
      const issues = []
    
      // Обход дерева
      function traverse(node, parent = null, context = { lastHeadingLevel: 0 }) {
        if (!node || typeof node !== 'object') return
    
        checkNode(node, context)
    
        if (Array.isArray(node.children)) {
          node.children.forEach(child => traverse(child, node, context))
        }
      }
    
      function checkNode(node, context) {
        const type = node.type?.toLowerCase()
        const props = node.props || {}
    
        // 1. img без alt
        if (type === 'img' && props.alt === undefined) {
          issues.push({
            type: VIOLATIONS.MISSING_ALT,
            element: 'img',
            message: 'Изображение без атрибута alt',
            severity: 'error',
            fix: 'Добавьте alt="описание изображения" или alt="" для декоративных',
          })
        }
    
        // 2. button без текста и без aria-label
        if (type === 'button') {
          const hasText = node.text || node.children?.some(c => typeof c === 'string' && c.trim())
          const hasAriaLabel = props['aria-label'] || props['aria-labelledby']
          if (!hasText && !hasAriaLabel) {
            issues.push({
              type: VIOLATIONS.MISSING_BUTTON_TEXT,
              element: 'button',
              message: 'Кнопка без текста и без aria-label',
              severity: 'error',
              fix: 'Добавьте текст внутрь кнопки или атрибут aria-label',
            })
          }
        }
    
        // 3. div с onClick вместо button
        if ((type === 'div' || type === 'span') && props.onClick) {
          issues.push({
            type: VIOLATIONS.DIV_CLICK,
            element: type,
            message: '<' + type + '> с обработчиком onClick — не интерактивный элемент',
            severity: 'warning',
            fix: 'Замените на <button> или добавьте role="button" + tabIndex="0" + onKeyDown',
          })
        }
    
        // 4. input без label
        if (type === 'input' && props.type !== 'hidden') {
          const hasLabel = props['aria-label'] || props['aria-labelledby'] || props.id
          if (!hasLabel) {
            issues.push({
              type: VIOLATIONS.FORM_NO_LABEL,
              element: 'input',
              message: 'Поле ввода без привязанного label',
              severity: 'error',
              fix: 'Добавьте <label htmlFor="id"> или aria-label к input',
            })
          }
        }
    
        // 5. Пропуск уровней заголовков (h1 -> h3)
        const headingMatch = type?.match(/^h([1-6])$/)
        if (headingMatch) {
          const level = parseInt(headingMatch[1])
          if (context.lastHeadingLevel && level > context.lastHeadingLevel + 1) {
            issues.push({
              type: VIOLATIONS.HEADING_SKIP,
              element: type,
              message: 'Пропуск уровня заголовков: h' + context.lastHeadingLevel + ' → ' + type,
              severity: 'warning',
              fix: 'Используйте заголовки последовательно без пропусков',
            })
          }
          context.lastHeadingLevel = level
        }
      }
    
      return {
        audit(tree) {
          issues.length = 0
          traverse(tree)
          return [...issues]
        },
        getIssueCount() { return issues.length },
      }
    }
    
    // --- Тест с проблемным деревом ---
    
    const badTree = {
      type: 'div',
      children: [
        { type: 'h1', children: ['Главная страница'] },
        // Пропуск h2 — сразу h3
        { type: 'h3', children: ['Секция'] },
        // img без alt
        { type: 'img', props: { src: 'photo.jpg' } },
        // Картинка с alt — ОК
        { type: 'img', props: { src: 'logo.png', alt: 'Логотип' } },
        // div с onClick
        { type: 'div', props: { onClick: () => {} }, children: ['Нажми'] },
        // button без текста
        { type: 'button', props: { 'aria-label': 'Закрыть' } },  // OK — есть aria-label
        { type: 'button', props: {}, children: [] },              // Ошибка!
        // input без label
        { type: 'input', props: { type: 'text' } },
        // input с label — OK
        { type: 'input', props: { type: 'email', id: 'email', 'aria-label': 'Email' } },
      ]
    }
    
    const auditor = createA11yAuditor()
    const violations = auditor.audit(badTree)
    
    console.log('=== A11y Аудит ===')
    console.log('Найдено нарушений:', violations.length)
    violations.forEach((v, i) => {
      console.log('\n' + (i + 1) + '. [' + v.severity.toUpperCase() + '] ' + v.element)
      console.log('   Проблема:', v.message)
      console.log('   Исправление:', v.fix)
    })

    Доступность (a11y) в React

    Что такое доступность и зачем это важно

    Доступность (accessibility, a11y) — создание интерфейсов, которыми могут пользоваться люди с ограниченными возможностями: слабовидящие (скринридеры), люди с моторными нарушениями (только клавиатура), глухие (субтитры).

    a11y — аббревиатура: первая буква "a", потом 11 букв, потом "y".

    Почему это важно:

  • Правовые требования (ADA в США, EN 301 549 в Европе)
  • Увеличение аудитории (15% людей имеют инвалидность)
  • Улучшает UX для всех: клавиатурная навигация, понятные подписи
  • Влияет на SEO (скринридеры и поисковики читают похоже)
  • Семантический HTML в JSX

    В JSX нужно использовать правильные HTML-теги по смыслу:

    // Плохо: div-суп
    function BadExample() {
      return (
        <div onClick={handleClick}>Нажми меня</div>  // не кнопка!
      )
    }
    
    // Хорошо: семантика
    function GoodExample() {
      return (
        <button onClick={handleClick}>Нажми меня</button>
      )
    }
    
    // Плохо: нет структуры заголовков
    <div className="title">Главная</div>
    <div className="subtitle">О нас</div>
    
    // Хорошо: иерархия h1-h6
    <h1>Главная</h1>
    <h2>О нас</h2>

    ARIA атрибуты

    ARIA (Accessible Rich Internet Applications) — атрибуты для описания интерактивных элементов:

    // aria-label: текстовое описание для скринридера
    <button aria-label="Закрыть модальное окно">
      ✕
    </button>
    
    // aria-labelledby: ссылка на элемент-подпись
    <div role="dialog" aria-labelledby="dialog-title">
      <h2 id="dialog-title">Подтверждение</h2>
      <p>Удалить элемент?</p>
    </div>
    
    // aria-describedby: дополнительное описание
    <input
      id="email"
      aria-describedby="email-hint"
      type="email"
    />
    <p id="email-hint">Мы не будем передавать ваш email третьим лицам.</p>
    
    // aria-expanded: состояние раскрытия
    <button aria-expanded={isOpen} aria-controls="menu">
      Меню
    </button>
    
    // aria-live: живые регионы для уведомлений
    <div aria-live="polite" aria-atomic="true">
      {statusMessage}
    </div>
    
    // role: указывает роль элемента
    <div role="alert">Произошла ошибка</div>
    <div role="status">Данные сохранены</div>
    <nav role="navigation" aria-label="Главная навигация">...</nav>

    Управление фокусом

    import { useRef, useEffect } from 'react'
    
    function Modal({ isOpen, onClose, children }) {
      const firstFocusableRef = useRef(null)
      const closeButtonRef = useRef(null)
    
      // При открытии перемещаем фокус внутрь модала
      useEffect(() => {
        if (isOpen) {
          firstFocusableRef.current?.focus()
        }
      }, [isOpen])
    
      // Ловушка фокуса: Tab не уходит из модала
      function handleKeyDown(e) {
        if (e.key === 'Escape') onClose()
    
        if (e.key === 'Tab') {
          const focusable = modalRef.current.querySelectorAll(
            'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
          )
          const first = focusable[0]
          const last = focusable[focusable.length - 1]
    
          if (e.shiftKey && document.activeElement === first) {
            last.focus()
            e.preventDefault()
          } else if (!e.shiftKey && document.activeElement === last) {
            first.focus()
            e.preventDefault()
          }
        }
      }
    
      if (!isOpen) return null
    
      return (
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby="modal-title"
          onKeyDown={handleKeyDown}
        >
          <h2 id="modal-title" ref={firstFocusableRef} tabIndex={-1}>
            Заголовок
          </h2>
          {children}
          <button ref={closeButtonRef} onClick={onClose}>Закрыть</button>
        </div>
      )
    }

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

    // Информационное изображение — нужен alt
    <img src="chart.png" alt="График роста продаж: +23% в Q4 2024" />
    
    // Декоративное изображение — пустой alt (скринридер пропускает)
    <img src="divider.png" alt="" role="presentation" />
    
    // Иконка в кнопке — alt на иконке или aria-label на кнопке
    <button aria-label="Удалить">
      <img src="trash.svg" alt="" />  {/* alt="" т.к. кнопка уже подписана */}
    </button>

    Формы: labels и ошибки

    function AccessibleForm() {
      const [errors, setErrors] = useState({})
    
      return (
        <form>
          <div>
            <label htmlFor="name">
              Имя <span aria-hidden="true">*</span>
              <span className="sr-only">(обязательное поле)</span>
            </label>
            <input
              id="name"
              name="name"
              required
              aria-required="true"
              aria-invalid={!!errors.name}
              aria-describedby={errors.name ? 'name-error' : undefined}
            />
            {errors.name && (
              <span id="name-error" role="alert">
                {errors.name}
              </span>
            )}
          </div>
        </form>
      )
    }

    Инструменты проверки

    # eslint-plugin-jsx-a11y — статический анализ в редакторе
    npm install eslint-plugin-jsx-a11y -D
    
    # .eslintrc
    { "plugins": ["jsx-a11y"], "extends": ["plugin:jsx-a11y/recommended"] }
    
    # axe-core — аудит в рантайме (для разработки)
    npm install @axe-core/react -D
    // Только в development режиме
    if (process.env.NODE_ENV !== 'production') {
      const axe = require('@axe-core/react')
      axe(React, ReactDOM, 1000)  // проверка каждую секунду
    }

    Примеры

    Инспектор доступности на ванильном JS: анализ дерева элементов на отсутствие alt, labels, нарушение иерархии заголовков и несемантические интерактивные элементы

    // Симулируем аудит доступности: проверяем дерево элементов
    // на типичные a11y нарушения.
    
    // --- Типы нарушений ---
    
    const VIOLATIONS = {
      MISSING_ALT: 'missing-alt',
      MISSING_LABEL: 'missing-label',
      MISSING_BUTTON_TEXT: 'missing-button-text',
      DIV_CLICK: 'div-with-click',
      HEADING_SKIP: 'heading-skip',
      FORM_NO_LABEL: 'form-no-label',
    }
    
    // --- Аудитор ---
    
    function createA11yAuditor() {
      const issues = []
    
      // Обход дерева
      function traverse(node, parent = null, context = { lastHeadingLevel: 0 }) {
        if (!node || typeof node !== 'object') return
    
        checkNode(node, context)
    
        if (Array.isArray(node.children)) {
          node.children.forEach(child => traverse(child, node, context))
        }
      }
    
      function checkNode(node, context) {
        const type = node.type?.toLowerCase()
        const props = node.props || {}
    
        // 1. img без alt
        if (type === 'img' && props.alt === undefined) {
          issues.push({
            type: VIOLATIONS.MISSING_ALT,
            element: 'img',
            message: 'Изображение без атрибута alt',
            severity: 'error',
            fix: 'Добавьте alt="описание изображения" или alt="" для декоративных',
          })
        }
    
        // 2. button без текста и без aria-label
        if (type === 'button') {
          const hasText = node.text || node.children?.some(c => typeof c === 'string' && c.trim())
          const hasAriaLabel = props['aria-label'] || props['aria-labelledby']
          if (!hasText && !hasAriaLabel) {
            issues.push({
              type: VIOLATIONS.MISSING_BUTTON_TEXT,
              element: 'button',
              message: 'Кнопка без текста и без aria-label',
              severity: 'error',
              fix: 'Добавьте текст внутрь кнопки или атрибут aria-label',
            })
          }
        }
    
        // 3. div с onClick вместо button
        if ((type === 'div' || type === 'span') && props.onClick) {
          issues.push({
            type: VIOLATIONS.DIV_CLICK,
            element: type,
            message: '<' + type + '> с обработчиком onClick — не интерактивный элемент',
            severity: 'warning',
            fix: 'Замените на <button> или добавьте role="button" + tabIndex="0" + onKeyDown',
          })
        }
    
        // 4. input без label
        if (type === 'input' && props.type !== 'hidden') {
          const hasLabel = props['aria-label'] || props['aria-labelledby'] || props.id
          if (!hasLabel) {
            issues.push({
              type: VIOLATIONS.FORM_NO_LABEL,
              element: 'input',
              message: 'Поле ввода без привязанного label',
              severity: 'error',
              fix: 'Добавьте <label htmlFor="id"> или aria-label к input',
            })
          }
        }
    
        // 5. Пропуск уровней заголовков (h1 -> h3)
        const headingMatch = type?.match(/^h([1-6])$/)
        if (headingMatch) {
          const level = parseInt(headingMatch[1])
          if (context.lastHeadingLevel && level > context.lastHeadingLevel + 1) {
            issues.push({
              type: VIOLATIONS.HEADING_SKIP,
              element: type,
              message: 'Пропуск уровня заголовков: h' + context.lastHeadingLevel + ' → ' + type,
              severity: 'warning',
              fix: 'Используйте заголовки последовательно без пропусков',
            })
          }
          context.lastHeadingLevel = level
        }
      }
    
      return {
        audit(tree) {
          issues.length = 0
          traverse(tree)
          return [...issues]
        },
        getIssueCount() { return issues.length },
      }
    }
    
    // --- Тест с проблемным деревом ---
    
    const badTree = {
      type: 'div',
      children: [
        { type: 'h1', children: ['Главная страница'] },
        // Пропуск h2 — сразу h3
        { type: 'h3', children: ['Секция'] },
        // img без alt
        { type: 'img', props: { src: 'photo.jpg' } },
        // Картинка с alt — ОК
        { type: 'img', props: { src: 'logo.png', alt: 'Логотип' } },
        // div с onClick
        { type: 'div', props: { onClick: () => {} }, children: ['Нажми'] },
        // button без текста
        { type: 'button', props: { 'aria-label': 'Закрыть' } },  // OK — есть aria-label
        { type: 'button', props: {}, children: [] },              // Ошибка!
        // input без label
        { type: 'input', props: { type: 'text' } },
        // input с label — OK
        { type: 'input', props: { type: 'email', id: 'email', 'aria-label': 'Email' } },
      ]
    }
    
    const auditor = createA11yAuditor()
    const violations = auditor.audit(badTree)
    
    console.log('=== A11y Аудит ===')
    console.log('Найдено нарушений:', violations.length)
    violations.forEach((v, i) => {
      console.log('\n' + (i + 1) + '. [' + v.severity.toUpperCase() + '] ' + v.element)
      console.log('   Проблема:', v.message)
      console.log('   Исправление:', v.fix)
    })

    Задание

    Создай доступный React компонент AccessibleForm с полем ввода и сообщениями об ошибках для скринридеров. Компонент должен: иметь label связанный с input через htmlFor/id, показывать ошибку если поле пустое при отправке, использовать aria-invalid и aria-describedby для связи поля с сообщением об ошибке, использовать role="alert" для сообщения об ошибке.

    Подсказка

    Ошибки: "Поле email обязательно" и "Введите корректный email". htmlFor и id должны совпадать: "email-input". aria-invalid={!!error ? "true" : "false"} или просто !!error. aria-describedby="email-error" когда есть ошибка. id ошибки: "email-error". role="alert" для мгновенного объявления скринридером.

    Загружаем среду выполнения...
    Загружаем AI-помощника...