נגישותCSS ופריסותריאקט

Popover API ו-dialog הטבעי: הסוף למודלים שבונים ידנית מאפס

Popover API ו-<dialog> הטבעי: הסוף למודלים שבונים ידנית מאפס

במאמר על נגישות רכיבים הצגתי מודל שנעלם ויזואלית באמצעות display: none, אבל בעץ הנגישות עדיין היה שם, כי מישהו הסתיר אותו בצורה שגויה. זו הייתה רק אחת מתוך חמש בעיות שמודל שבונים מאפס על <div> חייב לפתור ידנית כדי בכלל להיחשב נגיש: מלכודת פוקוס (Tab לא יכול להוציא את המשתמש מחוץ למודל), סגירה במקש Escape, סגירה בלחיצה מחוץ לתוכן, החזרת הפוקוס לאלמנט שפתח את המודל, ורינדור בפועל מעל שאר הדף, בלי קשר לכמה רכיבים בדרך יש להם overflow: hidden או z-index משלהם.

jsx

// נראה כמו מודל – לא פותר בפועל אף אחת מחמש הבעיות למעלה
const NaiveModal = ({ isOpen, onClose, children }) => {
if (!isOpen) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal">{children}</div>
</div>
);
};

לקוד הזה אין מלכודת פוקוס – Tab מוציא בחופשיות את משתמש המקלדת חזרה לשאר הדף, למרות שוויזואלית המודל עדיין מכסה הכול. הוא לא נסגר ב-Escape. הוא לא מחזיר פוקוס לטריגר אחרי סגירה. והוא תלוי בכך שלאף הורה בעץ ה-DOM אין overflow: hidden או z-index נמוך יותר מאלמנט אחר בדף – הנחה שבאפליקציה גדולה מתבררת כשגויה שוב ושוב. ספריות כמו Radix או Headless UI קיימות בדיוק בשביל לפתור את זה פעם אחת, כמו שצריך, ב-JavaScript. כבר כמה שנים חלק מהבעיה הזו הדפדפן פותר בעצמו, בלי שורת JS אחת.

Top layer: השכבה שה-z-index לא נוגע בה

המנגנון המרכזי שעליו מבוססים גם <dialog> וגם התכונה popover הוא ה-top layer – שכבת רינדור פנימית של הדפדפן, פיזית מעל כל שאר המסמך, בלתי תלויה לחלוטין ב-z-index, overflow או הקשרי stacking. אלמנט שמקודם ל-top layer לא "מקבל z-index גבוה מאוד" – הוא כבר לא חלק מערימת השכבות הרגילה של הדף, כך שאף הורה עם overflow: hidden לא יכול לחתוך אותו, ואף אלמנט שכן עם z-index: 9999 אבסורדי לא יכול לכסות אותו. זה בדיוק המנגנון שהדפדפן כבר השתמש בו זמן רב באופן פנימי לאלמנטים טבעיים כמו <video> במצב מסך מלא – ה-Popover API וה-<dialog> פשוט חושפים אותו למפתחים.

jsx

const ConfirmDialog = ({ onConfirm }) => {
const dialogRef = useRef(null);
return (
<dialog ref={dialogRef} className="confirm-dialog">
<p>למחוק את הפריט הזה בוודאות?</p>
<form method="dialog" className="confirm-dialog__actions">
<button type="submit" value="cancel">ביטול</button>
<button type="submit" value="confirm" onClick={onConfirm}>
מחיקה
</button>
</form>
</dialog>
);
};
// במקום אחר ברכיב:
// dialogRef.current.showModal();

הקריאה ל-.showModal() (לא .show() – טעות נפוצה, .show() פותח דיאלוג לא-מודלי, בלי רקע ובלי מלכודת פוקוס) מפעילה בבת אחת ארבעה דברים בחינם: קידום ל-top layer, רינדור של פסאודו-אלמנט ::backdrop שמעמעם את שאר הדף, מלכודת פוקוס (Tab מסתובב במעגל רק בתוך הדיאלוג), והפיכת שאר המסמך ל-inert – אלמנטים מחוץ לדיאלוג מפסיקים להיות ניתנים למיקוד ונעלמים מהאינטראקציה עבור קורא מסך, למרות שהם עדיין נראים מתחת לרקע המעומעם. סגירה באמצעות Escape או באמצעות <form method="dialog"> (מנגנון טבעי – לחיצה על כפתור submit בתוך טופס כזה סוגרת את הדיאלוג וקובעת את dialog.returnValue לפי ה-value של הכפתור שנלחץ, בלי preventDefault ובלי handler ב-JS) מחזירה אוטומטית את הפוקוס לאלמנט שפתח את הדיאלוג. אף אחד מארבעת הדברים האלה לא דורש כתיבה עצמאית.

Popover API: אותו דבר בלי מודליות

<dialog> מניח שיש לחסום את שאר הדף. זה הגיוני לאישור מחיקה, אבל כבד מדי לתפריט נפתח, tooltip או toast – דברים שאמורים להופיע מעל שאר התוכן, להיסגר מעצמם בלחיצה בצד, אבל לא אמורים לחסום אינטראקציה עם שאר הדף. בשביל זה קיימת התכונה popover, באופן דקלרטיבי, בלי שורת JavaScript אחת:

html

<button popovertarget="user-menu">חשבון</button>
<div id="user-menu" popover>
<a href="/profile">פרופיל</a>
<a href="/settings">הגדרות</a>
<button popovertarget="user-menu" popovertargetaction="hide">התנתקות</button>
</div>

עצם הקשר popovertarget/id מספיק כדי שהדפדפן יטפל בכל השאר: קידום ל-top layer, light dismiss (לחיצה בכל מקום מחוץ לפופאובר או Escape סוגרים אותו אוטומטית, בלי האזנה ל-click על ה-document), וקשרי ARIA נכונים בין הטריגר לתוכן (הדפדפן בעצמו מקשר aria-expanded ו-aria-controls על בסיס הצמד הזה של תכונות). זה בדיוק מה שקודם דרש ספרייה כמו Floating UI או האזנה ידנית ללחיצות מחוץ לאלמנט עם בדיקה נוספת של event.target.closest().

ההבדל המהותי לעומת <dialog> במצב מודלי: פופאובר לא הופך את שאר הדף ל-inert ולא כולל מלכודת פוקוס. זו החלטה מודעת של המפרט, לא חוסר בפיצ'ר – תפריט משתמש לא אמור לחסום גלילה או לחיצה במקום אחר בדף, כמו שמודל אמיתי עושה. בלבול בין שני הכלים האלה בכיוון הלא נכון – שימוש ב-popover במקום שהתוכן באמת חייב לחסום את שאר הממשק (למשל אישור פעולה בלתי הפיכה) – נותן ממשק שבו משתמש מקלדת יכול בכל רגע לצאת ב-Tab מה"מודל" שבכלל לא חסם אותו.

מלכודת שהתיעוד לפעמים שותק לגביה: לחיצה על הרקע של <dialog> לא סוגרת אותו אוטומטית

למרות ש-::backdrop מתרנדר בחינם, לחיצה עליו לא סוגרת את הדיאלוג בלי קוד נוסף – זה אחד הדברים המעטים שצריך להוסיף ידנית. המנגנון שמאפשר את זה מבוסס על עובדה ספציפית לגבי hit-testing: אלמנט ה-<dialog> עצמו במצב מודלי ממלא את כל השטח הנראה מבחינת hit-testing, בלי קשר לכמה קטן ויזואלית ה-box עם התוכן שלו – כך שלחיצה על הרקע המעומעם עדיין פוגעת ב-event.target === dialogElement, ולחיצה על תוכן בפנים פוגעת באלמנט הצאצא הספציפי.

jsx

const handleBackdropClick = (event) => {
// event.target הוא ה-<dialog> עצמו רק כשהלחיצה פגעה ברקע,
// לא באף אלמנט מהתוכן שבפנים
if (event.target === dialogRef.current) {
dialogRef.current.close();
}
};
// <dialog ref={dialogRef} onClick={handleBackdropClick}>

כדאי לזכור את הטריק הזה בנפרד, כי אינטואיטיבית "הרי הרקע מתרנדר לבד, כנראה שגם נסגר לבד" – הוא לא נסגר, וזה האלמנט היחיד מהסט הזה שדורש שורת JS שנכתבת בעצמכם.

מלכודות ושיטות עבודה טובות

  • .show() זה לא אותו דבר כמו .showModal(). .show() פותח דיאלוג לא-מודלי – בלי ::backdrop, בלי מלכודת פוקוס, בלי inert על שאר הדף. למודל אמיתי תמיד צריך .showModal().
  • את הסגנונות ברירת המחדל של <dialog> צריך לאפס במודע, בדיוק כמו בכל אלמנט טבעי אחר מהמאמר על נגישות (<button>, <ul>) – הדפדפן נותן לו border, padding ומרכוז ברירת מחדל, שבפועל כמעט תמיד דורסים עם CSS משלכם.
  • פופאובר לא מחליף את <dialog> במקום שבו נדרשת מודליות. היעדר מלכודת פוקוס והיעדר inert הם מגבלה מכוונת של הפופאובר, לא פרצה שצריך לעקוף – אם התוכן חייב לחסום את שאר הדף, הכלי הנכון הוא תמיד <dialog>.showModal().
  • תמיכה: <dialog> בטוח לשימוש כבר הרבה זמן, popover חדש יותר. ל-<dialog> (כולל showModal()) יש תמיכה יציבה כבר משנת 2022. התכונה popover הגיעה ל-Baseline (זמינה באופן נרחב) מאוחר יותר, ב-2024 – עדיין כדאי לבדוק את גרסת ה-Safari המינימלית הנתמכת בפרויקטים עם זנב ארוך של מכשירים ישנים, לפני שוותרים לגמרי על ספריית JS כ-fallback.

סיכום

מודל שבונים ידנית על <div> הוא לא רע בגלל שהמפתח לא השקיע מספיק – הוא רע כי הוא מנסה לשחזר ב-JavaScript מנגנון שהדפדפן כיום מציע באופן טבעי ובחינם: top layer שבלתי תלוי ב-z-index, מלכודת פוקוס, inert על שאר הדף, ::backdrop, החזרת פוקוס לטריגר. <dialog> ו-popover הם לא שני וריאנטים של אותו דבר – הם שני כלים בשני צדדים מנוגדים של אותו גבול: מודליות שחוסמת במודע את שאר הממשק, ותוכן קליל וזמני שבמודע לא חוסם אותו. הבחירה ביניהם היא לא עניין של סגנון, אלא תשובה לשאלה האם המשתמש אמור להיות מסוגל באותו רגע לעשות משהו אחר בדף – ושאלה זו כדאי לשאול לפני כתיבת שורת הקוד הראשונה, לא בדיעבד, כשמתברר שהתפריט חוסם גלילה, או שמודל אישור מחיקת החשבון בכלל לא חוסם את ה-Tab.