Accessibility в JavaScript: как сделать сайт удобным для всех
Представьте, что вы заходите на сайт, а кнопка «Купить» не нажимается мышкой — только с клавиатуры. Или вы не видите экран, а скринридер просто пропускает половину контента. Неприятно, правда? А ведь для миллионов людей это — ежедневная реальность.
Accessibility (или a11y) — это не просто модный термин. Это способ сделать ваш код честным. Доступность в JavaScript — это про то, чтобы ваш сайт работал для всех: для людей с ограниченным зрением, с моторными нарушениями, для тех, кто пользуется только клавиатурой или голосовым управлением. И да, это ещё и про SEO: поисковики любят чистый, семантически правильный код.
В этой статье мы разберём реальные примеры на JavaScript, которые можно внедрить уже сегодня. Никакой воды — только код и практика.
Почему accessibility — это ваша ответственность?
Если вы пишете на JavaScript, вы управляете поведением интерфейса. Именно JS чаще всего ломает доступность: динамические модалки, бесконечные скроллы, кастомные селекты. Скринридеры (программы чтения с экрана) не умеют читать ваши мысли — они читают то, что вы им скажете через HTML и ARIA.
Хорошая новость: исправить большинство проблем можно за вечер. Плохая: если не думать об accessibility на старте, переписывать придётся неделями.
ARIA-атрибуты: ваш главный инструмент
ARIA (Accessible Rich Internet Applications) — это набор атрибутов, которые помогают вспомогательным технологиям понять, что происходит на странице. JavaScript идеально подходит для того, чтобы динамически обновлять эти атрибуты.
Пример 1: Динамическое обновление aria-live
Допустим, у вас есть чат, где сообщения подгружаются без перезагрузки. Скринридер должен объявить новое сообщение. Без aria-live пользователь просто не узнает об обновлении.
// Создаём контейнер для уведомлений
const liveRegion = document.createElement('div');
liveRegion.setAttribute('aria-live', 'polite');
liveRegion.setAttribute('aria-atomic', 'true');
liveRegion.classList.add('sr-only'); // скрываем визуально
document.body.appendChild(liveRegion);
// Функция добавления сообщения
function addChatMessage(message) {
liveRegion.textContent = ''; // очищаем
liveRegion.textContent = `Новое сообщение: ${message}`;
}
// Пример вызова
addChatMessage('Привет! Как дела?');
Важно: aria-live="polite" говорит скринридеру: «Объяви, когда закончишь читать текущее». aria-live="assertive" прерывает чтение — используйте для критичных ошибок.
Пример 2: Кастомный спойлер/аккордеон
Стандартные <details> и <summary> уже доступны, но если вы делаете кастомный аккордеон на JS, нужно добавить ARIA-роли и состояния.
// Для каждой секции аккордеона
const accordionButton = document.querySelector('.accordion-button');
const accordionPanel = document.querySelector('.accordion-panel');
accordionButton.setAttribute('aria-expanded', 'false');
accordionButton.setAttribute('aria-controls', 'panel-1');
accordionPanel.setAttribute('id', 'panel-1');
accordionPanel.setAttribute('aria-hidden', 'true');
accordionButton.addEventListener('click', () => {
const isOpen = accordionButton.getAttribute('aria-expanded') === 'true';
accordionButton.setAttribute('aria-expanded', !isOpen);
accordionPanel.setAttribute('aria-hidden', isOpen);
});
Теперь скринридер понимает, что кнопка управляет панелью, и знает, открыта она или закрыта.
Управление фокусом: не дайте пользователю потеряться
Одна из самых частых ошибок в JavaScript доступности — потеря фокуса. Когда открывается модальное окно, фокус должен переместиться внутрь. Когда закрывается — вернуться обратно на кнопку, которая его вызвала.
Пример: Модальное окно с правильным фокусом
function openModal(modalId) {
const modal = document.getElementById(modalId);
const lastFocused = document.activeElement; // запоминаем, где был фокус
modal.classList.add('modal--open');
modal.setAttribute('aria-hidden', 'false');
// Перемещаем фокус на первый элемент внутри модалки
const firstFocusable = modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (firstFocusable) {
firstFocusable.focus();
}
// Сохраняем lastFocused для закрытия
modal.dataset.lastFocused = lastFocused.id || '';
// Обработчик закрытия
modal.querySelector('.modal-close').addEventListener('click', () => {
closeModal(modalId);
});
}
function closeModal(modalId) {
const modal = document.getElementById(modalId);
modal.classList.remove('modal--open');
modal.setAttribute('aria-hidden', 'true');
// Возвращаем фокус
const lastFocusedId = modal.dataset.lastFocused;
if (lastFocusedId) {
document.getElementById(lastFocusedId).focus();
}
}
Этот код — база. Для продакшена добавьте ещё обработку Escape и ловушку фокуса (чтобы нельзя было выйти за пределы модалки табуляцией).
Клавиатурная навигация: не только для Tab
Многие пользователи не используют мышь. Они перемещаются по сайту с помощью клавиш Tab, Enter, пробела и стрелок. Ваша задача — сделать так, чтобы все интерактивные элементы были доступны с клавиатуры.
Пример: Кастомный select (выпадающий список)
class AccessibleSelect {
constructor(container) {
this.container = container;
this.button = container.querySelector('.custom-select__button');
this.list = container.querySelector('.custom-select__list');
this.items = container.querySelectorAll('.custom-select__item');
this.currentIndex = -1;
this.button.addEventListener('keydown', (e) => this.handleButtonKeydown(e));
this.list.addEventListener('keydown', (e) => this.handleListKeydown(e));
this.items.forEach((item, index) => {
item.addEventListener('click', () => this.selectItem(index));
});
}
handleButtonKeydown(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.toggleList();
}
if (e.key === 'ArrowDown') {
e.preventDefault();
this.openList();
this.focusItem(0);
}
}
handleListKeydown(e) {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.focusItem(this.currentIndex + 1);
}
if (e.key === 'ArrowUp') {
e.preventDefault();
this.focusItem(this.currentIndex - 1);
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
this.selectItem(this.currentIndex);
}
if (e.key === 'Escape') {
this.closeList();
this.button.focus();
}
}
focusItem(index) {
if (index < 0) index = this.items.length - 1;
if (index >= this.items.length) index = 0;
this.currentIndex = index;
this.items[index].focus();
}
selectItem(index) {
this.button.textContent = this.items[index].textContent;
this.closeList();