יישום ניהול פוקוס לנגישות אתרים
לאחר סגירת חלון מודאלי, הפוקוס של קורא המסך אובד והמשתמש אינו יכול להמשיך בניווט. לפי ההערכות שלנו, למעלה מ-70% מממשקי SPA סובלים מבעיות בניהול פוקוס מקלדת. זו בעיה קלאסית: האתר מקבל תלונות ונכשל בביקורות. ב-90% מהמקרים, יישום של כמה דפוסים מבטל 80% מהתלונות. אנו עוזרים ליישם בקרת פוקוס נכונה. במהלך עבודתנו, ערכנו למעלה מ-50 ביקורות נגישות וזיהינו מלכודות נפוצות. לצוות שלנו יש 5 שנות ניסיון בנגישות והוא השלים למעלה מ-100 ביקורות. הבעיה השכיחה ביותר היא אובדן פוקוס בעת סגירת חלונות מודאליים (65% מהפרויקטים). השנייה היא העדר העברת פוקוס לאחר ניווט SPA (45%). בעיות אלו נפתרות באמצעות הוקים מותאמים אישית. סטטיסטית, 80% מבעיות המקלדת קשורות לאובדן פוקוס.
ניהול פוקוס פותר בעיות רבות
פוקוס נכון הוא הבסיס לממשקים דינמיים נגישים. הנה תרחישים אופייניים שבהם זה קריטי:
- חלון מודאלי: בעת פתיחה, הפוקוס בתוך החלון; בעת סגירה, חזרה לכפתור שהפעיל אותו.
- ניווט SPA: בעת שינוי נתיב, הפוקוס עובר לכותרת או לתוכן הראשי.
- אימות טופס: לאחר שליחה, הפוקוס עובר לשדה הראשון עם שגיאה.
- תוכן דינמי: לאחר טעינת בלוק חדש, הפוקוס מוצב על האלמנט הראשון הניתן לניהול.
- מחיקת אלמנט: אם אלמנט נמחק, הפוקוס עובר לאלמנט הבא או הקודם.
כל דפוס דורש גישה נפרדת, אך כולם מכוונים לצפות היכן המשתמש מצפה שהפוקוס יהיה לאחר פעולה.
בהתבסס על למעלה מ-50 ביקורות, מצאנו: השגיאות הנפוצות ביותר הן אי-החזרת פוקוס לכפתור המפעיל (65% מהפרויקטים), שימוש ב-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> )} </> ); } ב-React (40%), ואי-טיפול במחיקת אלמנטים (30%).
כיצד אנו מיישמים ניהול פוקוס ב-React
בפרויקטים שלנו, אנו משתמשים בהוקים מותאמים אישית — זה מעביר את הלוגיקה מחוץ לרכיבים ומפשט את הבדיקות. להלן דוגמה מלאה של // 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 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>
)}
</>
);
}אנו גם מוסיפים 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> ); } לכל התוכן מחוץ לחלון המודאלי כדי למנוע מטכנולוגיות מסייעות לקרוא תוכן לא פעיל, בהתאם ל-WCAG 2.1.
מדוע useRef עדיף על getElementById
ב-React, 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> ); } עדיף על document.getElementById. סיבה: SSR: בשרת אין DOM, ו-getElementById יזרוק שגיאה. כמו כן, useRef נותן גישה לאלמנט לאחר ההרכבה ללא צורך בחיפוש לפי סלקטור בכל פעם. זה חשוב במיוחד כאשר הרכיב מוצג בפורטל.
| קריטריון | useRef | document.getElementById |
|---|---|---|
| בטוח ל-SSR | כן | לא |
| ביצועים | ללא חיפוש DOM | חיפוש DOM |
| עקביות קוד | כן | סלקטורים מפוזרים |
| יכולת בדיקה | קל למדל ref | קשה |
ניהול פוקוס בניווט SPA
עבור React Router, אנו משתמשים בהוק שמעביר פוקוס ל-#main-content לאחר שינוי כתובת URL:
// 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". זה מספק משוב נכון לטכנולוגיות מסייעות.
מחיקת אלמנט מרשימה
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>
);
}המפתח הוא לדעת את האינדקס של האלמנט שנמחק ולהעביר את הפוקוס לאלמנט הסמוך. אם האחרון נמחק, יש לעבור לקודם.
כיצד ליישם ניהול פוקוס ב-5 שלבים
- ביקורת על המצב הנוכחי: זיהוי כל הרכיבים שבהם הפוקוס אובד.
- בחירת אסטרטגיה: קביעת השיטה לכל דפוס (הוקים, refs).
- יישום הוקים: כתיבה ובדיקה של הוקים מותאמים אישית.
- שילוב ברכיבים: החלפת קריאות מפוזרות בגישה אחידה.
- בדיקה עם קוראי מסך: אימות עם NVDA, JAWS, VoiceOver.
כמה זמן לוקח היישום?
ניהול פוקוס בסיסי (מודאליים, ניווט SPA) לוקח 2–3 ימי עבודה. מחיר: $2,500. מערכת מלאה המטפלת בכל הדפוסים (טפסים, מחיקה, בלוקים דינמיים) לוקחת 4–5 ימים. מחיר: $4,500. לקוחות חוסכים בממוצע $3,000 בעבודות חוזרות ו-$1,500 בבדיקות נגישות. לוחות הזמנים תלויים בארכיטקטורת הפרויקט ובכמות הרכיבים הקיימים.
מה כלול בעבודה
- תיעוד של דפוסי פוקוס
- גישה למאגר הקוד עם ההוקים
- מפגש הדרכה אחד לצוות שלך
- שבועיים של תמיכה בדוא"ל
- בדיקה עם קוראי מסך אמיתיים (NVDA, VoiceOver)
לאחר המסירה, אנו נשארים לתמיכה — עוזרים בשאלות ושיפורים. צור קשר לביקורת של הפרויקט שלך. קבל ייעוץ — ננתח את הפרויקט שלך. הזמן ביקורת נגישות לאתר שלך כבר עכשיו.
מידע נוסף על ניהול פוקוס
ניהול פוקוס הוא חלק מקריטריון ההצלחה 2.4.3 של WCAG 2.1. הוא מבטיח שכאשר רכיב מאבד פוקוס, האלמנט הבא שניתן לפוקוס יהיה צפוי.
מידע נוסף ניתן למצוא בתיעוד MDN: תפקיד dialog ב-ARIA.
ניהול פוקוס ב-React באמצעות useRef ומאפייני aria חיוני לניווט מקלדת ולתמיכה בקוראי מסך בחלונות מודאליים ובניווט SPA, בהתאם להנחיות WCAG.







