Доступность (Accessibility, a11y) — возможность использовать приложение людьми с ограниченными возможностями: незрячими (скринридеры), слабовидящими, людьми с двигательными нарушениями (клавиатурная навигация), с когнитивными особенностями.
Это не только этический вопрос. В большинстве стран существуют законодательные требования к доступности веб-приложений (WCAG 2.1). Кроме того, доступный код — это чистый, семантически правильный код.
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>
)
}В модальном окне фокус не должен "убегать" за его пределы при нажатии 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>
)
}Автоматическая проверка доступности:
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.
// Скринридер объявит новый статус автоматически:
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)
})Доступность (Accessibility, a11y) — возможность использовать приложение людьми с ограниченными возможностями: незрячими (скринридеры), слабовидящими, людьми с двигательными нарушениями (клавиатурная навигация), с когнитивными особенностями.
Это не только этический вопрос. В большинстве стран существуют законодательные требования к доступности веб-приложений (WCAG 2.1). Кроме того, доступный код — это чистый, семантически правильный код.
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>
)
}В модальном окне фокус не должен "убегать" за его пределы при нажатии 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>
)
}Автоматическая проверка доступности:
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.
// Скринридер объявит новый статус автоматически:
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" для роли модального окна.