← React/Доступность (a11y) в React-приложениях#284 из 430← ПредыдущийСледующий →+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-помощника...