Реалізація Focus Management для доступності сайту
Focus Management — управління тим, на якому елементі знаходиться клавіатурний фокус у динамічних інтерфейсах. Неправильний фокус робить SPA непригідним для використання користувачами екранних читалок та клавіатурної навігації.
Коли потрібно управляти фокусом
- Відкриття/закриття модального вікна
- Навігація між сторінками в SPA
- Появлення/скриття динамічного вмісту
- Завершення багатокрокового процесу (wizard)
- Видалення елемента зі списку
- Сповіщення про помилки валідації форми
Модальне вікно: повний цикл
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>
)}
</>
);
}
Навігація в SPA (React Router)
// 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]);
}
Валідація форми: фокус на першу помилку
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>
);
}
Видалення елемента зі списку
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>
);
}
useRef проти getElementById
Переважно використовувати useRef замість document.getElementById у React — це безпечніше для SSR та тестування.
Часові рамки
Базове управління фокусом (модалі, навігація SPA): 2–3 дні. Повна система з обробкою всіх паттернів: 4–5 днів.







