יצירת רכיבים נגישים לקוראי מסך
יצירת רכיבים נגישים לקוראי מסך
תארו לעצמכם משתמש/ת NVDA שפותח/ת חלונית מודאלית עם טופס, ממלא/ה אותו ולוחץ/ת על "סגור". המודאל נעלם ויזואלית – display: none ב-CSS, הכול נראה תקין. אלא שהאלמנט עדיין יושב ב-DOM, הפוקוס לא זז לשום מקום, וקורא המסך ממשיך להקריא את תוכן החלון שכביכול "נסגר". המשתמש/ת שומע/ת את תוכן הטופס שזה עתה ביטל/ה, ואין לו/לה מושג היכן הוא/היא נמצא/ת בעמוד עכשיו. זו לא דוגמה מומצאת – זו אחת הטעויות הנפוצות ביותר ברכיבים שנבנים "לפי העין", בלי הבנה אמיתית של אופן פעולת הנגישות. במאמר הזה אני רוצה לחרוג מרשימת כללים ולהראות את המנגנון שמאחוריהם – וגם כמה רכיבים אמיתיים שבהם רואים בדיוק איפה המלכודות.
איך קורא מסך באמת "רואה" עמוד
לפני שמתקנים רכיבים, כדאי להבין עם מה קורא מסך בעצם עובד. הוא לא מרנדר את העמוד כמו שדפדפן עושה – הוא פועל על עץ הנגישות (accessibility tree), מבנה שהדפדפן בונה במקביל ל-DOM. לכל צומת בעץ הזה יש שלוש תכונות בסיסיות: תפקיד (role) – האם זה כפתור, קישור, כותרת או שדה טופס; שם (name) – מה שהמשתמש שומע כתיאור שלו; ומצב (state) – פתוח, מסומן, מושבת.
המסקנה המרכזית: קורא מסך לא רואה את ה-CSS שלכם. display: none ו-visibility: hidden מסירים אלמנט מעץ הנגישות – זו דרך תקינה להסתיר משהו. אבל פשוט להזיז אלמנט מחוץ למסך (position: absolute; left: -9999px) או לצבוע אותו ב-transparent לא משנה כלום בעץ הנגישות – האלמנט עדיין שם ועדיין מוקרא. בדיוק זה מה שהשתבש בדוגמת המודאל למעלה.
העמוד השני הוא סדר ה-DOM. במצב גלישה רגיל, קורא מסך מקריא את העמוד לפי הסדר שבו האלמנטים מופיעים בעץ – בלי קשר לאיך שסידרתם אותם ויזואלית עם flex-direction: row-reverse או grid-template-areas. אם הפריסה נראית הגיונית ויזואלית אבל סדר המקור שרירותי, משתמש/ת עיוור/ת מקבל/ת את העמוד "בסדר אקראי".
סמנטיקה כקו ההגנה הראשון
הדרך הזולה ביותר להשיג נגישות נכונה היא להשתמש באלמנט ה-HTML הנכון במקום להמציא מחדש את ההתנהגות שלו מאפס. שימו לב להבדל:
jsx
// רע – נראה ככפתור, אבל הוא לאconst SaveButton = ({ onSave }) => (<div className="btn" onClick={onSave}>שמור שינויים</div>);
ל-div הזה יש פערים רציניים שלא בולטים במבט ראשון:
- הוא לא ניתן למיקוד (focusable) – אי אפשר להגיע אליו עם מקש Tab.
- הוא לא מגיב למקלדת – Enter ו-Space לא עושים כלום, כי ה-
onClickשל React מאזין רק לעכבר/מגע. - אין לו תפקיד
button– קורא מסך מקריא אותו כטקסט רגיל, בלי שום סימן שאפשר "להפעיל" אותו. - אין לו מצב מושבת – אי אפשר סתם להגדיר
disabled, צריך לדמות את זה ידנית.
jsx
// טוב – מקבלים את כל האמור למעלה בחינםconst SaveButton = ({ onSave, isSaving }) => (<button type="button" onClick={onSave} disabled={isSaving}>{isSaving ? "שומר…" : "שמור שינויים"}</button>);
אותו עיקרון חל על <nav> במקום <div className="nav">, <label> שמקושר לשדה דרך htmlFor במקום placeholder שמעמיד פנים שהוא label, או <ul>/<li> לרשימות במקום ערימה של <div>-ים. HTML סמנטי הוא לא "יותר יפה" – הוא ממש יוצר מבנה שונה ועשיר יותר בעץ הנגישות.
ARIA: מתי היא עוזרת, ומתי היא מזיקה
מפרט ה-WAI-ARIA נפתח בכלל ששווה לשנן מילה במילה: "אין ARIA עדיפה על ARIA גרועה" (No ARIA is better than Bad ARIA). תכונות ARIA לא מוסיפות התנהגות – הן רק דורסות את מה שקורא המסך מדווח למשתמש. אם אתם מבטיחים משהו שהרכיב לא באמת מסוגל לספק, המצב שלכם גרוע יותר מאשר אם לא הייתם מוסיפים כלום.
jsx
// רע – ARIA מבטיחה כפתור, אבל שום דבר אחר לא תומך בכךconst DeleteIcon = ({ onDelete }) => (<span role="button" aria-label="מחק פריט" onClick={onDelete}>🗑</span>);
הקוד הזה אומר לקורא המסך "זה כפתור" – אבל הוא לא מוסיף תמיכה במקלדת ולא tabIndex, כך שמשתמש/ת מקלדת לעולם לא יוכל/תוכל להגיע אליו. זה גרוע יותר מאשר בלי role בכלל, כי זה נותן רושם שהפיצ'ר קיים כשהוא פיזית בלתי נגיש.
jsx
// טוב – פשוט השתמשו בכפתור מקוריconst DeleteIcon = ({ onDelete }) => (<button type="button" onClick={onDelete} aria-label="מחק פריט"><span aria-hidden="true">🗑</span></button>);
שימו לב ל-aria-hidden="true" על האמוג'י – בלעדיו, חלק מקוראי המסך ינסו להקריא את שם התו ביוניקוד ("פח אשפה"), מה שנשמע אבסורדי מיד אחרי שה-aria-label כבר הוקרא. aria-label מחליף לגמרי את התוכן הגלוי במה שהמשתמש שומע – אם לאלמנט כבר יש טקסט קריא, לרוב עדיף aria-labelledby שמצביע על אותו טקסט, כדי לא לתחזק שני תיאורים נפרדים שעלולים להתרחק זה מזה עם הזמן.
דוגמה מעשית: אקורדיון נגיש
כפתור פשוט לא מראה הרבה. בואו נסתכל על רכיב שבו באמת צריך לנהל מצב ויחסי ARIA באופן מכוון – אקורדיון, דפוס הקטע המתרחב הנפוץ בשאלות נפוצות (FAQ) או בפאנלים של הגדרות.
jsx
import { useId, useState } from "react";const AccordionItem = ({ title, children, defaultOpen = false }) => {const [isOpen, setIsOpen] = useState(defaultOpen);const contentId = useId();return (<div className="accordion-item"><h3 className="accordion-header"><buttontype="button"className="accordion-trigger"aria-expanded={isOpen}aria-controls={contentId}onClick={() => setIsOpen((open) => !open)}>{title}<span className="accordion-icon" aria-hidden="true">{isOpen ? "−" : "+"}</span></button></h3><div id={contentId} role="region" aria-labelledby={contentId} hidden={!isOpen}>{children}</div></div>);};export default AccordionItem;
כמה החלטות בקוד הזה הן לא מקריות:
aria-expandedמדווח את המצב הנוכחי – בלעדיו, משתמש/ת קורא מסך פשוט שומע/ת "כפתור", בלי שום דרך לדעת אם הקטע פתוח.aria-controlsמקשר את הכפתור לפאנל שהוא שולט בו – חלק מקוראי המסך מכריזים על הקשר הזה, מה שמקל על הניווט.hidden(ולא רק הסתרה דרך CSS) מבטיח שתוכן סגור באמת מוסר מעץ הנגישות ומסדר ה-Tab, כך שהפוקוס לא יכול לנחות על תוכן בלתי נראה.- ה-
<h3>שעוטף את הכפתור שומר על היררכיית הכותרות שלמה – משתמשי קוראי מסך מנווטים לעיתים קרובות מאוד לפי כותרות, קופצים בין קטעים עם מקש H. - לאייקון
+/−ישaria-hidden="true", כי המצב כבר מתוקשר דרךaria-expanded– בלעדיו, קורא מסך היה מכריז עליו פעמיים, בצורה מבלבלת.
אזורים חיים והודעות דינמיות
בעיה נפוצה נוספת: משהו משתנה בעמוד בלי רענון, וקורא המסך אף פעם לא שם לב, כי אין לו סיבה להקריא מחדש קטע שהמשתמש לא בוחן כרגע. דוגמה קלאסית היא שגיאת אימות שמופיעה דינמית אחרי יציאה משדה:
jsx
import { useState } from "react";const EmailField = () => {const [error, setError] = useState("");const handleBlur = (event) => {const value = event.target.value;setError(value.includes("@") ? "" : "הזן כתובת אימייל תקינה.");};return (<div className="field"><label htmlFor="email">אימייל</label><inputid="email"type="email"aria-invalid={Boolean(error)}aria-describedby={error ? "email-error" : undefined}onBlur={handleBlur}/><span id="email-error" role="alert" className="field-error">{error}</span></div>);};export default EmailField;
role="alert" גורם לאלמנט להתנהג כמו אזור aria-live="assertive" מרומז – כשהתוכן שלו משתנה, קורא המסך מפסיק כל מה שהוא מקריא כרגע ומכריז מיד על ההודעה החדשה. זה מתאים לשגיאות שדורשות תשומת לב דחופה, אבל לא כדאי להגזים בשימוש בו לעדכונים פחות דחופים (כמו "הטיוטה נשמרה") – aria-live="polite" מתאים יותר שם, כי הוא מחכה שהמשתמש יסיים את הפעולה הנוכחית במקום להפריע לו. aria-describedby גם מקשר את הודעת השגיאה לשדה, כך שקורא המסך מקריא אותה יחד עם ה-label בכל פעם שהמשתמש חוזר לשדה הזה – לא רק ברגע שהשגיאה הופיעה לראשונה.
ניהול פוקוס בניווט ב-Next.js
המלכודת הזו ספציפית לאפליקציות עמוד יחיד (SPA), כולל Next.js App Router. במעבר עמוד קלאסי (רענון מלא), הדפדפן מאפס את הפוקוס ל-<body>, וקורא המסך מכריז על כותרת המסמך החדש – המשתמש יודע שהוא נחת בעמוד חדש. עם ניווט בצד הלקוח, שום דבר מזה לא קורה אוטומטית: הפוקוס נשאר על הקישור שנלחץ (לעיתים קרובות איפשהו בתפריט הניווט, מחוץ לתוכן החדש), וקורא המסך לא מקבל שום איתות שמשהו בכלל השתנה.
jsx
"use client";import { usePathname } from "next/navigation";import { useEffect, useRef } from "react";const RouteAnnouncer = ({ pageTitle }) => {const pathname = usePathname();const headingRef = useRef(null);useEffect(() => {headingRef.current?.focus();}, [pathname]);return (<h1 ref={headingRef} tabIndex={-1} className="visually-focusable-heading">{pageTitle}</h1>);};export default RouteAnnouncer;
הטריק הוא tabIndex={-1} – בדרך כלל כותרות אינן ניתנות למיקוד, אבל הערך הזה מאפשר להזיז אליהן פוקוס באופן פרוגרמטי (.focus()) בלי להוסיף אותן לסדר ה-Tab הטבעי. אחרי כל שינוי נתיב (pathname), הפוקוס חוזר לכותרת של העמוד החדש, כך שקורא המסך מכריז על הכותרת שלו – בדיוק כפי שהיה קורה ברענון מסורתי. זה רכיב אחד ששווה להוסיף ל-layout פעם אחת ולא לחשוב עליו שוב לעולם.
איך באמת בודקים נגישות
כלים אוטומטיים כמו axe-core או eslint-plugin-jsx-a11y שווה להפעיל כבר מהיום הראשון של פרויקט – הם תופסים את הטעויות הברורות (alt חסר, ניגודיות ירודה, labels חסרים) לפני שהן מגיעות ל-production. אבל כדאי להכיר את המגבלות שלהם: לפי מחקר של Deque Systems, כלים אוטומטיים תופסים בפועל בערך 30–40% מבעיות הנגישות. השאר דורש בדיקה ידנית, כי מדובר במשמעות ובהקשר שמכונה לא יכולה להעריך – האם סדר ההקראה באמת הגיוני, האם הודעת שגיאה באמת מסבירה מה לעשות, האם מלכודת פוקוס במודאל באמת לא נותנת למשתמש לברוח ממנה.
תהליך קונקרטי וניתן לחזרה שתופס את רוב הבעיות האמיתיות:
- הניחו את העכבר בצד ועברו על כל הזרימה רק עם המקלדת – Tab, Shift+Tab, Enter, Space, Escape, מקשי חצים בכל מקום שזה טבעי (למשל בתפריט). אם בשלב כלשהו אתם לא יודעים איפה הפוקוס נמצא, זו כבר באג.
- הפעילו את VoiceOver (macOS: Cmd+F5) או NVDA (Windows, חינמי) ועברו על אותה זרימה עם עיניים עצומות. רק זה חושף אם הסדר, השמות והמצבים באמת הגיוניים בקול, ולא רק על הנייר.
- בדקו הודעות דינמיות בנפרד – הפעילו שגיאת טופס, שנו נתיב, פתחו מודאל – ובדקו אם קורא המסך באמת הכריז על משהו, לא רק אם לאלמנט יש את התכונה הנכונה בקוד.
סיכום
רכיבים נגישים אינם רשימת תכונות שמדביקים בסוף – הם תוצאה של איך שאתם ממדלים מצב ומבנה כבר מההתחלה. שלושה דברים ששווה לקחת מהמאמר הזה: ראשית, HTML סמנטי נותן לכם מיקוד, תמיכה במקלדת ותפקיד נכון בחינם – ARIA צריכה למלא את מה ש-HTML לא יכול לבטא, לא להחליף אותו ברכיבים שיכלו באותה מידה להיות אלמנט מקורי. שנית, שינויים במצב ובתוכן חייבים להיות מוכרזים באופן פעיל – aria-live, role="alert" וניהול פוקוס אחרי ניווט אינם תוספות, הם התנאי לכך שאפליקציה דינמית תהיה שמישה בכלל בלי ראייה. שלישית, שום כלי אוטומטי לא מחליף מעבר על הממשק שלכם בעצמכם עם עיניים עצומות – זו הדרך המהירה ביותר לראות בדיוק איפה הרכיב ה"נגיש" שלכם בעצם מאבד את המשתמש.