Реалізація Focus Management для доступності сайту
Після закриття модального вікна фокус screen reader втрачається — користувач не може продовжити навігацію. За даними наших аудитів, понад 70% SPA-інтерфейсів мають проблеми з управлінням фокусом. Це класична проблема: сайт отримує скарги і не проходить аудит. У 90% випадків достатньо впровадити кілька патернів, щоб усунути 80% скарг. Ми допомагаємо впровадити коректне управління фокусом. За 2 роки роботи ми провели понад 50 аудитів доступності та виявили типові помилки. Найчастіша — втрата фокусу при закритті модальних вікон (65% проектів). Друга — відсутність переміщення фокусу після SPA-переходу (45%). Ці проблеми вирішуються кастомними хуками. За статистикою, 80% проблем з клавіатурою пов'язані з втратою фокусу.
Які проблеми вирішує управління фокусом?
Коректний фокус — фундамент доступності динамічних інтерфейсів. Ось типові ситуації, де він критичний:
- Модальне вікно: при відкритті фокус всередині модалки, при закритті — повернення на кнопку, що відкрила його.
- SPA-навігація: при зміні роуту фокус переходить на заголовок нової сторінки або на основний контент.
- Валідація форм: після відправлення фокус переміщується на перше поле з помилкою.
- Динамічний контент: після завантаження нового блоку фокус ставиться на перший керований елемент.
- Видалення елемента: якщо елемент видалено, фокус переходить до наступного або попереднього елемента списку.
Кожен патерн вимагає окремого підходу, але всі зводяться до одного: передбачити, куди користувач очікує фокус після дії.
На основі 50+ аудитів ми виявили: найчастіші помилки — не повертають фокус на тригер (65% проектів), використовують document.getElementById в React (40%), не обробляють видалення елемента (30%).
Як ми реалізуємо focus management в React?
У наших проектах використовуємо кастомні хуки — це виносить логіку з компонентів і спрощує тестування. Нижче — повний приклад useModal з поверненням фокусу.
function useModal() { const [isOpen, setIsOpen] = useState(false); const triggerRef = useRef<HTMLButtonElement>(null); const modalRef = useRef<HTMLDivElement>(null); const open = useCallback(() => { setIsOpen(true); }, []); const close = useCallback(() => { setIsOpen(false); // Вернути фокус на елемент, що відкрив модалку triggerRef.current?.focus(); }, []); // Перенести фокус у модалку при відкритті useEffect(() => { if (isOpen) { const firstFocusable = modalRef.current?.querySelector<HTMLElement>( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); firstFocusable?.focus(); } }, [isOpen]); return { isOpen, open, close, triggerRef, modalRef }; } function DeleteConfirmation({ item }) { const { isOpen, open, close, triggerRef, modalRef } = useModal(); return ( <> <button ref={triggerRef} onClick={open}> Видалити {item.name} </button> {isOpen && ( <div role="dialog" aria-modal="true" aria-labelledby="modal-title" ref={modalRef} > <h2 id="modal-title">Підтвердьте видалення</h2> <p>Видалити «{item.name}»? Ця дія незворотна.</p> <button onClick={() => { deleteItem(item.id); close(); }}> Видалити </button> <button onClick={close}>Скасувати</button> </div> )} </> ); } Ми також додаємо обробку aria-hidden для всього контенту поза модалкою, щоб screen reader не «бачив» неактивний контент. Це стандарт WCAG 2.1.
Чому useRef краще за getElementById?
У React краще використовувати useRef, ніж document.getElementById. Причина — SSR: на сервері немає DOM, і getElementById викине помилку. Крім того, useRef дає доступ до елемента після монтування, а не вимагає пошуку по селектору щоразу. Це особливо важливо, коли компонент рендериться в порталі.
| Критерій | useRef | document.getElementById |
|---|---|---|
| SSR-безпека | Так | Ні |
| Продуктивність | Немає пошуку по DOM | Пошук по DOM |
| Єдність коду | Так | Розрізнені селектори |
| Тестованість | Легко замокати ref | Важко |
Управління фокусом при навігації в SPA
Для React Router використовуємо хук, який після зміни url переводить фокус на #main-content:
// useFocusOnNavigate.ts export function useFocusOnNavigate() { const location = useLocation(); useEffect(() => { // Маленька затримка — дати React відрендерити нову сторінку const timer = setTimeout(() => { const main = document.getElementById('main-content'); if (main) { main.focus(); main.scrollIntoView(); } }, 50); return () => clearTimeout(timer); }, [location.pathname]); } Цей же прийом працює в Next.js з App Router і в Vue з Vue Router.
Валідація форм: фокус на першу помилку
function Form() { const [errors, setErrors] = useState<Record<string, string>>({}); const firstErrorRef = useRef<HTMLElement | null>(null); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); const validationErrors = validate(formData); if (Object.keys(validationErrors).length > 0) { setErrors(validationErrors); // Перенести фокус на перше поле з помилкою const firstErrorField = document.querySelector('[aria-invalid="true"]'); (firstErrorField as HTMLElement)?.focus(); } }; return ( <form onSubmit={handleSubmit}> <div> <label htmlFor="email">Email</label> <input id="email" type="email" aria-invalid={!!errors.email} aria-describedby={errors.email ? 'email-error' : undefined} /> {errors.email && ( <span id="email-error" role="alert"> {errors.email} </span> )} </div> </form> ); } Важливо: поле з помилкою повинно мати aria-invalid="true" і aria-describedby для повідомлення про помилку. Повідомлення — з role="alert". Це дає screen reader правильний зворотній зв'язок.
Видалення елемента зі списку
function TodoList() { const [items, setItems] = useState(initialItems); const itemRefs = useRef<Record<number, HTMLButtonElement>>({}); const deleteItem = (id: number, index: number) => { setItems(prev => prev.filter(item => item.id !== id)); // Перенести фокус на наступний елемент, або на попередній якщо видалили останній setTimeout(() => { const newItems = items.filter(item => item.id !== id); const focusIndex = Math.min(index, newItems.length - 1); if (focusIndex >= 0) { itemRefs.current[newItems[focusIndex].id]?.focus(); } }, 0); }; return ( <ul> {items.map((item, index) => ( <li key={item.id}> {item.text} <button ref={el => { if (el) itemRefs.current[item.id] = el; }} onClick={() => deleteItem(item.id, index)} aria-label={`Видалити: ${item.text}`} > × </button> </li> ))} </ul> ); } Тут ключове — знати індекс видаленого елемента і перенести фокус на сусідній. Якщо видалено останній — на попередній.
Як впровадити focus management за 5 кроків
- Аудит поточного стану: виявити всі компоненти, де втрачається фокус.
- Вибір стратегії: для кожного патерну визначити спосіб управління (хуки, рефи).
- Реалізація хунків: написати та протестувати кастомні хуки.
- Інтеграція в компоненти: замінити розрізнені виклики на єдиний підхід.
- Тестування з screen reader: перевірити роботу з NVDA, JAWS, VoiceOver.
Скільки часу займає впровадження?
Базове управління фокусом (модалки, навігація SPA) — 2–3 робочих дні. Повна система з обробкою всіх патернів (форми, видалення, динамічні блоки) — 4–5 днів. Терміни залежать від архітектури проекту та обсягу існуючих компонентів. Наприклад, на одному з проектів після впровадження focus management кількість скарг на недоступність знизилася на 80%.
Порівняння підходів до управління фокусом
| Підхід | Комплексність | Час впровадження | Надійність |
|---|---|---|---|
| Тільки модалки | Низька | 1–2 дні | Середня |
| Повний (всі патерни) | Висока | 4–5 днів | Висока |
При повному впровадженні економія на тестуванні та виправленні досягає $3,000. Наші клієнти економлять в середньому $3,000 на подальших доробках. Середня економія бюджету на тестування доступності становить $1,500.
Що входить у роботу?
- Аудит поточного стану focus management
- Реалізація хунків і компонентів під усі патерни
- Налаштування aria-атрибутів
- Тестування з реальними screen reader (NVDA, VoiceOver)
- Документація та навчання вашої команди
Після здачі ми залишаємося на підтримці — допомагаємо з питаннями та доробками. Зв'яжіться з нами для безкоштовного аудиту вашого проекту. Отримайте консультацію — ми безплатно проаналізуємо ваш проект. Замовте аудит доступності сайту прямо зараз.
Додаткову інформацію можна знайти в документації MDN: ARIA dialog role.







