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

Доступность (a11y) в React-приложениях

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

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

Это не только этический вопрос. В большинстве стран существуют законодательные требования к доступности веб-приложений (WCAG 2.1). Кроме того, доступный код — это чистый, семантически правильный код.

ARIA: атрибуты для скринридеров

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

// role — говорит что это за элемент
<div role="button" onClick={handleClick}>Нажми</div>  // хуже
<button onClick={handleClick}>Нажми</button>           // лучше — нативный button имеет role="button" автоматически

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

// aria-labelledby — связывает с элементом-заголовком
<h2 id="dialog-title">Подтверждение</h2>
<div role="dialog" aria-labelledby="dialog-title">...</div>

// aria-describedby — дополнительное описание
<input aria-describedby="email-hint" type="email" />
<p id="email-hint">Введите корпоративный email</p>

// aria-expanded — для выпадающих меню, аккордеонов
<button aria-expanded={isOpen} aria-controls="menu">Меню</button>
<ul id="menu" hidden={!isOpen}>...</ul>

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

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

Правильный фокус критичен для клавиатурной навигации:

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef(null)
  const triggerRef = useRef(null)  // элемент, который открыл модал

  useEffect(() => {
    if (isOpen) {
      // При открытии — фокус на модал
      modalRef.current?.focus()
    }
  }, [isOpen])

  // Возвращаем фокус на триггер при закрытии
  function handleClose() {
    onClose()
    triggerRef.current?.focus()
  }

  if (!isOpen) return null

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      tabIndex={-1}  // делает div фокусируемым
    >
      {children}
      <button onClick={handleClose}>Закрыть</button>
    </div>
  )
}

Ловушка фокуса (Focus Trap)

В модальном окне фокус не должен "убегать" за его пределы при нажатии Tab:

function useFocusTrap(ref) {
  useEffect(() => {
    const el = ref.current
    if (!el) return

    const focusableElements = el.querySelectorAll(
      'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
    const firstElement = focusableElements[0]
    const lastElement = focusableElements[focusableElements.length - 1]

    function handleKeyDown(e) {
      if (e.key !== 'Tab') return

      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          lastElement.focus()
          e.preventDefault()
        }
      } else {
        if (document.activeElement === lastElement) {
          firstElement.focus()
          e.preventDefault()
        }
      }
    }

    el.addEventListener('keydown', handleKeyDown)
    firstElement?.focus()  // фокус на первый элемент при открытии

    return () => el.removeEventListener('keydown', handleKeyDown)
  }, [ref])
}

Клавиатурная навигация

Пользователи без мыши используют Tab (следующий элемент), Shift+Tab (предыдущий), Enter/Space (активация), стрелки (в списках):

function MenuButton({ items }) {
  const [isOpen, setIsOpen] = useState(false)
  const [activeIndex, setActiveIndex] = useState(-1)

  function handleKeyDown(e) {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        setActiveIndex(i => Math.min(i + 1, items.length - 1))
        break
      case 'ArrowUp':
        e.preventDefault()
        setActiveIndex(i => Math.max(i - 1, 0))
        break
      case 'Escape':
        setIsOpen(false)
        break
      case 'Enter':
      case ' ':
        if (activeIndex >= 0) items[activeIndex].onClick()
        break
    }
  }

  return (
    <div onKeyDown={handleKeyDown}>
      <button
        aria-haspopup="menu"
        aria-expanded={isOpen}
        onClick={() => setIsOpen(!isOpen)}
      >
        Действия
      </button>
      {isOpen && (
        <ul role="menu">
          {items.map((item, i) => (
            <li
              key={item.id}
              role="menuitem"
              aria-selected={i === activeIndex}
              tabIndex={i === activeIndex ? 0 : -1}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

eslint-plugin-jsx-a11y

Автоматическая проверка доступности:

npm install --save-dev eslint-plugin-jsx-a11y
// .eslintrc:
{
  "plugins": ["jsx-a11y"],
  "extends": ["plugin:jsx-a11y/recommended"]
}

Поймает: интерактивные div без role, img без alt, form без label, and more.

Live Regions: динамические объявления

// Скринридер объявит новый статус автоматически:
function SearchStatus({ count, isLoading }) {
  return (
    <div
      role="status"
      aria-live="polite"   // 'polite' (подождёт паузы) или 'assertive' (прервёт)
      aria-atomic="true"   // объявит весь блок целиком, не частями
    >
      {isLoading ? 'Поиск...' : 'Найдено результатов: ' + count}
    </div>
  )
}

Примеры

Реализация доступного модального окна: ловушка фокуса через Tab, закрытие по Escape, aria-атрибуты, управление фокусом при открытии/закрытии

// Реализуем доступный Modal на чистом JavaScript:
// focus trap, Escape to close, aria-атрибуты, возврат фокуса.

// --- Вспомогательные функции ---

function getFocusableElements(container) {
  const selector = [
    'a[href]',
    'button:not([disabled])',
    'input:not([disabled])',
    'select:not([disabled])',
    'textarea:not([disabled])',
    '[tabindex]:not([tabindex="-1"])',
  ].join(', ')

  return Array.from(container.querySelectorAll(selector))
}

// --- Класс Modal ---

class AccessibleModal {
  constructor(config) {
    this.title = config.title || 'Диалог'
    this.content = config.content || ''
    this.onClose = config.onClose || (() => {})

    this.element = null
    this.previouslyFocused = null  // куда вернуть фокус
    this.isOpen = false

    this.handleKeyDown = this.handleKeyDown.bind(this)
  }

  open(triggerElement) {
    if (this.isOpen) return

    // Сохраняем элемент, который открыл модал
    this.previouslyFocused = triggerElement || document.activeElement

    // Создаём DOM
    this.element = document.createElement('div')
    this.element.setAttribute('role', 'dialog')
    this.element.setAttribute('aria-modal', 'true')
    this.element.setAttribute('aria-labelledby', 'modal-title')
    this.element.setAttribute('aria-describedby', 'modal-desc')
    this.element.setAttribute('tabindex', '-1')  // чтобы можно было сфокусировать div
    this.element.style.cssText = [
      'position: fixed',
      'inset: 0',
      'background: rgba(0,0,0,0.5)',
      'display: flex',
      'align-items: center',
      'justify-content: center',
      'z-index: 1000',
    ].join(';')

    const dialog = document.createElement('div')
    dialog.style.cssText = 'background: white; padding: 24px; border-radius: 8px; min-width: 300px;'

    const titleEl = document.createElement('h2')
    titleEl.id = 'modal-title'
    titleEl.textContent = this.title

    const descEl = document.createElement('p')
    descEl.id = 'modal-desc'
    descEl.textContent = this.content

    const closeBtn = document.createElement('button')
    closeBtn.textContent = 'Закрыть'
    closeBtn.setAttribute('aria-label', 'Закрыть диалог')
    closeBtn.addEventListener('click', () => this.close())

    const cancelBtn = document.createElement('button')
    cancelBtn.textContent = 'Отмена'
    cancelBtn.addEventListener('click', () => this.close())

    dialog.appendChild(titleEl)
    dialog.appendChild(descEl)
    dialog.appendChild(closeBtn)
    dialog.appendChild(cancelBtn)
    this.element.appendChild(dialog)

    // Клик по оверлею закрывает
    this.element.addEventListener('click', (e) => {
      if (e.target === this.element) this.close()
    })

    document.body.appendChild(this.element)
    document.addEventListener('keydown', this.handleKeyDown)

    this.isOpen = true

    // Фокус на первый интерактивный элемент (или на сам модал)
    const focusable = getFocusableElements(dialog)
    if (focusable.length > 0) {
      focusable[0].focus()
    } else {
      this.element.focus()
    }

    console.log('Modal открыт. Фокус установлен на:', document.activeElement?.tagName)
    console.log('aria-labelledby указывает на:', this.element.getAttribute('aria-labelledby'))
  }

  handleKeyDown(event) {
    if (!this.isOpen) return

    // Escape закрывает модал
    if (event.key === 'Escape') {
      console.log('Escape нажат — закрываем модал')
      this.close()
      return
    }

    // Tab: ловушка фокуса
    if (event.key === 'Tab') {
      const focusable = getFocusableElements(this.element)
      if (focusable.length === 0) return

      const firstEl = focusable[0]
      const lastEl = focusable[focusable.length - 1]

      if (event.shiftKey) {
        // Shift+Tab: идём назад
        if (document.activeElement === firstEl) {
          lastEl.focus()
          event.preventDefault()
          console.log('Focus trap: переход от первого к последнему')
        }
      } else {
        // Tab: идём вперёд
        if (document.activeElement === lastEl) {
          firstEl.focus()
          event.preventDefault()
          console.log('Focus trap: переход от последнего к первому')
        }
      }
    }
  }

  close() {
    if (!this.isOpen) return

    document.removeEventListener('keydown', this.handleKeyDown)
    this.element?.remove()
    this.element = null
    this.isOpen = false

    // Возвращаем фокус на элемент, который открыл модал
    this.previouslyFocused?.focus()
    console.log('Modal закрыт. Фокус возвращён на:', this.previouslyFocused?.tagName || 'нет')

    this.onClose()
  }
}

// --- Демонстрация без DOM ---
console.log('=== Проверка доступности модала (без DOM) ===')

// Симулируем логику focus trap без реального DOM
function simulateFocusTrap(focusableCount) {
  let currentIndex = 0
  const elements = Array.from({ length: focusableCount }, (_, i) => 'element-' + i)

  console.log('
Focus Trap с', focusableCount, 'фокусируемыми элементами:')

  function pressTab() {
    currentIndex = (currentIndex + 1) % elements.length
    console.log('Tab → фокус на:', elements[currentIndex])
  }

  function pressShiftTab() {
    currentIndex = (currentIndex - 1 + elements.length) % elements.length
    console.log('Shift+Tab → фокус на:', elements[currentIndex])
  }

  // Симулируем несколько нажатий Tab
  pressTab()
  pressTab()
  pressTab()
  pressTab()  // должен вернуться к первому
  pressShiftTab()  // назад
}

simulateFocusTrap(3)

// ARIA атрибуты
console.log('
=== Необходимые ARIA атрибуты для Modal ===')
const ariaRequirements = {
  'role="dialog"':              'Сообщает скринридеру что это диалог',
  'aria-modal="true"':          'Скринридер не читает фон',
  'aria-labelledby="title-id"': 'Привязка к заголовку',
  'aria-describedby="desc-id"': 'Привязка к описанию',
  'tabindex="-1"':              'Позволяет сфокусировать div программно',
}

Object.entries(ariaRequirements).forEach(([attr, description]) => {
  console.log(attr.padEnd(35), '←', description)
})

Доступность (a11y) в React-приложениях

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

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

Это не только этический вопрос. В большинстве стран существуют законодательные требования к доступности веб-приложений (WCAG 2.1). Кроме того, доступный код — это чистый, семантически правильный код.

ARIA: атрибуты для скринридеров

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

// role — говорит что это за элемент
<div role="button" onClick={handleClick}>Нажми</div>  // хуже
<button onClick={handleClick}>Нажми</button>           // лучше — нативный button имеет role="button" автоматически

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

// aria-labelledby — связывает с элементом-заголовком
<h2 id="dialog-title">Подтверждение</h2>
<div role="dialog" aria-labelledby="dialog-title">...</div>

// aria-describedby — дополнительное описание
<input aria-describedby="email-hint" type="email" />
<p id="email-hint">Введите корпоративный email</p>

// aria-expanded — для выпадающих меню, аккордеонов
<button aria-expanded={isOpen} aria-controls="menu">Меню</button>
<ul id="menu" hidden={!isOpen}>...</ul>

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

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

Правильный фокус критичен для клавиатурной навигации:

function Modal({ isOpen, onClose, children }) {
  const modalRef = useRef(null)
  const triggerRef = useRef(null)  // элемент, который открыл модал

  useEffect(() => {
    if (isOpen) {
      // При открытии — фокус на модал
      modalRef.current?.focus()
    }
  }, [isOpen])

  // Возвращаем фокус на триггер при закрытии
  function handleClose() {
    onClose()
    triggerRef.current?.focus()
  }

  if (!isOpen) return null

  return (
    <div
      ref={modalRef}
      role="dialog"
      aria-modal="true"
      tabIndex={-1}  // делает div фокусируемым
    >
      {children}
      <button onClick={handleClose}>Закрыть</button>
    </div>
  )
}

Ловушка фокуса (Focus Trap)

В модальном окне фокус не должен "убегать" за его пределы при нажатии Tab:

function useFocusTrap(ref) {
  useEffect(() => {
    const el = ref.current
    if (!el) return

    const focusableElements = el.querySelectorAll(
      'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
    const firstElement = focusableElements[0]
    const lastElement = focusableElements[focusableElements.length - 1]

    function handleKeyDown(e) {
      if (e.key !== 'Tab') return

      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          lastElement.focus()
          e.preventDefault()
        }
      } else {
        if (document.activeElement === lastElement) {
          firstElement.focus()
          e.preventDefault()
        }
      }
    }

    el.addEventListener('keydown', handleKeyDown)
    firstElement?.focus()  // фокус на первый элемент при открытии

    return () => el.removeEventListener('keydown', handleKeyDown)
  }, [ref])
}

Клавиатурная навигация

Пользователи без мыши используют Tab (следующий элемент), Shift+Tab (предыдущий), Enter/Space (активация), стрелки (в списках):

function MenuButton({ items }) {
  const [isOpen, setIsOpen] = useState(false)
  const [activeIndex, setActiveIndex] = useState(-1)

  function handleKeyDown(e) {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        setActiveIndex(i => Math.min(i + 1, items.length - 1))
        break
      case 'ArrowUp':
        e.preventDefault()
        setActiveIndex(i => Math.max(i - 1, 0))
        break
      case 'Escape':
        setIsOpen(false)
        break
      case 'Enter':
      case ' ':
        if (activeIndex >= 0) items[activeIndex].onClick()
        break
    }
  }

  return (
    <div onKeyDown={handleKeyDown}>
      <button
        aria-haspopup="menu"
        aria-expanded={isOpen}
        onClick={() => setIsOpen(!isOpen)}
      >
        Действия
      </button>
      {isOpen && (
        <ul role="menu">
          {items.map((item, i) => (
            <li
              key={item.id}
              role="menuitem"
              aria-selected={i === activeIndex}
              tabIndex={i === activeIndex ? 0 : -1}
            >
              {item.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

eslint-plugin-jsx-a11y

Автоматическая проверка доступности:

npm install --save-dev eslint-plugin-jsx-a11y
// .eslintrc:
{
  "plugins": ["jsx-a11y"],
  "extends": ["plugin:jsx-a11y/recommended"]
}

Поймает: интерактивные div без role, img без alt, form без label, and more.

Live Regions: динамические объявления

// Скринридер объявит новый статус автоматически:
function SearchStatus({ count, isLoading }) {
  return (
    <div
      role="status"
      aria-live="polite"   // 'polite' (подождёт паузы) или 'assertive' (прервёт)
      aria-atomic="true"   // объявит весь блок целиком, не частями
    >
      {isLoading ? 'Поиск...' : 'Найдено результатов: ' + count}
    </div>
  )
}

Примеры

Реализация доступного модального окна: ловушка фокуса через Tab, закрытие по Escape, aria-атрибуты, управление фокусом при открытии/закрытии

// Реализуем доступный Modal на чистом JavaScript:
// focus trap, Escape to close, aria-атрибуты, возврат фокуса.

// --- Вспомогательные функции ---

function getFocusableElements(container) {
  const selector = [
    'a[href]',
    'button:not([disabled])',
    'input:not([disabled])',
    'select:not([disabled])',
    'textarea:not([disabled])',
    '[tabindex]:not([tabindex="-1"])',
  ].join(', ')

  return Array.from(container.querySelectorAll(selector))
}

// --- Класс Modal ---

class AccessibleModal {
  constructor(config) {
    this.title = config.title || 'Диалог'
    this.content = config.content || ''
    this.onClose = config.onClose || (() => {})

    this.element = null
    this.previouslyFocused = null  // куда вернуть фокус
    this.isOpen = false

    this.handleKeyDown = this.handleKeyDown.bind(this)
  }

  open(triggerElement) {
    if (this.isOpen) return

    // Сохраняем элемент, который открыл модал
    this.previouslyFocused = triggerElement || document.activeElement

    // Создаём DOM
    this.element = document.createElement('div')
    this.element.setAttribute('role', 'dialog')
    this.element.setAttribute('aria-modal', 'true')
    this.element.setAttribute('aria-labelledby', 'modal-title')
    this.element.setAttribute('aria-describedby', 'modal-desc')
    this.element.setAttribute('tabindex', '-1')  // чтобы можно было сфокусировать div
    this.element.style.cssText = [
      'position: fixed',
      'inset: 0',
      'background: rgba(0,0,0,0.5)',
      'display: flex',
      'align-items: center',
      'justify-content: center',
      'z-index: 1000',
    ].join(';')

    const dialog = document.createElement('div')
    dialog.style.cssText = 'background: white; padding: 24px; border-radius: 8px; min-width: 300px;'

    const titleEl = document.createElement('h2')
    titleEl.id = 'modal-title'
    titleEl.textContent = this.title

    const descEl = document.createElement('p')
    descEl.id = 'modal-desc'
    descEl.textContent = this.content

    const closeBtn = document.createElement('button')
    closeBtn.textContent = 'Закрыть'
    closeBtn.setAttribute('aria-label', 'Закрыть диалог')
    closeBtn.addEventListener('click', () => this.close())

    const cancelBtn = document.createElement('button')
    cancelBtn.textContent = 'Отмена'
    cancelBtn.addEventListener('click', () => this.close())

    dialog.appendChild(titleEl)
    dialog.appendChild(descEl)
    dialog.appendChild(closeBtn)
    dialog.appendChild(cancelBtn)
    this.element.appendChild(dialog)

    // Клик по оверлею закрывает
    this.element.addEventListener('click', (e) => {
      if (e.target === this.element) this.close()
    })

    document.body.appendChild(this.element)
    document.addEventListener('keydown', this.handleKeyDown)

    this.isOpen = true

    // Фокус на первый интерактивный элемент (или на сам модал)
    const focusable = getFocusableElements(dialog)
    if (focusable.length > 0) {
      focusable[0].focus()
    } else {
      this.element.focus()
    }

    console.log('Modal открыт. Фокус установлен на:', document.activeElement?.tagName)
    console.log('aria-labelledby указывает на:', this.element.getAttribute('aria-labelledby'))
  }

  handleKeyDown(event) {
    if (!this.isOpen) return

    // Escape закрывает модал
    if (event.key === 'Escape') {
      console.log('Escape нажат — закрываем модал')
      this.close()
      return
    }

    // Tab: ловушка фокуса
    if (event.key === 'Tab') {
      const focusable = getFocusableElements(this.element)
      if (focusable.length === 0) return

      const firstEl = focusable[0]
      const lastEl = focusable[focusable.length - 1]

      if (event.shiftKey) {
        // Shift+Tab: идём назад
        if (document.activeElement === firstEl) {
          lastEl.focus()
          event.preventDefault()
          console.log('Focus trap: переход от первого к последнему')
        }
      } else {
        // Tab: идём вперёд
        if (document.activeElement === lastEl) {
          firstEl.focus()
          event.preventDefault()
          console.log('Focus trap: переход от последнего к первому')
        }
      }
    }
  }

  close() {
    if (!this.isOpen) return

    document.removeEventListener('keydown', this.handleKeyDown)
    this.element?.remove()
    this.element = null
    this.isOpen = false

    // Возвращаем фокус на элемент, который открыл модал
    this.previouslyFocused?.focus()
    console.log('Modal закрыт. Фокус возвращён на:', this.previouslyFocused?.tagName || 'нет')

    this.onClose()
  }
}

// --- Демонстрация без DOM ---
console.log('=== Проверка доступности модала (без DOM) ===')

// Симулируем логику focus trap без реального DOM
function simulateFocusTrap(focusableCount) {
  let currentIndex = 0
  const elements = Array.from({ length: focusableCount }, (_, i) => 'element-' + i)

  console.log('
Focus Trap с', focusableCount, 'фокусируемыми элементами:')

  function pressTab() {
    currentIndex = (currentIndex + 1) % elements.length
    console.log('Tab → фокус на:', elements[currentIndex])
  }

  function pressShiftTab() {
    currentIndex = (currentIndex - 1 + elements.length) % elements.length
    console.log('Shift+Tab → фокус на:', elements[currentIndex])
  }

  // Симулируем несколько нажатий Tab
  pressTab()
  pressTab()
  pressTab()
  pressTab()  // должен вернуться к первому
  pressShiftTab()  // назад
}

simulateFocusTrap(3)

// ARIA атрибуты
console.log('
=== Необходимые ARIA атрибуты для Modal ===')
const ariaRequirements = {
  'role="dialog"':              'Сообщает скринридеру что это диалог',
  'aria-modal="true"':          'Скринридер не читает фон',
  'aria-labelledby="title-id"': 'Привязка к заголовку',
  'aria-describedby="desc-id"': 'Привязка к описанию',
  'tabindex="-1"':              'Позволяет сфокусировать div программно',
}

Object.entries(ariaRequirements).forEach(([attr, description]) => {
  console.log(attr.padEnd(35), '←', description)
})

Задание

Создай доступное модальное окно `AccessibleModal` с ловушкой фокуса. Компонент должен: перемещать фокус на модал при открытии, закрываться по Escape, иметь правильные ARIA-атрибуты. Используй `useEffect` для обработки клавиатуры и `useRef` для управления фокусом. Заполни пропуски `???`.

Подсказка

useRef для modalRef, useEffect для подписки на события, "Escape" для закрытия по клавише, "dialog" для роли модального окна.

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