Доступность (accessibility, a11y) — создание интерфейсов, которыми могут пользоваться люди с ограниченными возможностями: слабовидящие (скринридеры), люди с моторными нарушениями (только клавиатура), глухие (субтитры).
a11y — аббревиатура: первая буква "a", потом 11 букв, потом "y".
Почему это важно:
В 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 (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>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)
})Доступность (accessibility, a11y) — создание интерфейсов, которыми могут пользоваться люди с ограниченными возможностями: слабовидящие (скринридеры), люди с моторными нарушениями (только клавиатура), глухие (субтитры).
a11y — аббревиатура: первая буква "a", потом 11 букв, потом "y".
Почему это важно:
В 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 (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>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" для мгновенного объявления скринридером.