בניית רכיב של תיבת דו-שיח

סקירה כללית בסיסית על יצירת חלונות מודולריים (modal) בגודל מיני ומגה עם התאמה לצבע, תגובה דינמית ונגישות באמצעות הרכיב <dialog>.

בפוסט הזה אני רוצה לשתף את הטיפים שלי ליצירת חלונות מודולריים (modal) מסוג מיני ומגה עם התאמה לצבע, תגובה למיקום המסך ונגישות, באמצעות הרכיב <dialog>. רוצים לנסות את הדמו ולעיין בקוד המקור?

הדגמה של תיבת הדו-שיח המגה ושל תיבת הדו-שיח המיני בעיצוב בהיר ובעיצוב כהה.

אם אתם מעדיפים סרטון, הנה גרסה של הפוסט הזה ב-YouTube:

סקירה כללית

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

לאחרונה, הרכיב <dialog> הפך ליציב בכל הדפדפנים:

תמיכה בדפדפנים

  • Chrome: 37.
  • Edge: ‏ 79.
  • Firefox: ‏ 98.
  • Safari: 15.4.

מקור

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

Markup

הרכיבים הבסיסיים של רכיב <dialog> הם צנועים. הרכיב יוסתר באופן אוטומטי ויש לו סגנונות מובנים להצגה מעל התוכן.

<dialog>
  …
</dialog>

אנחנו יכולים לשפר את הבסיס הזה.

באופן מסורתי, יש הרבה דמיון בין רכיב תיבת דו-שיח לבין חלון דו-שיח, ולעיתים קרובות אפשר להשתמש בשמות שלהם באופן הפוך. השתמשתי כאן ברכיב תיבת הדו-שיח גם בחלונות קופצים קטנים של תיבות דו-שיח (מיני), וגם בתיבות דו-שיח של דף מלא (מגה). קראתי להם 'מגה' ו'מיני', ושני תיבת הדו-שיח הותאמו מעט לתרחישים לדוגמה שונים. הוספתי מאפיין modal-mode כדי לאפשר לך לציין את הסוג:

<dialog id="MegaDialog" modal-mode="mega"></dialog>
<dialog id="MiniDialog" modal-mode="mini"></dialog>

צילום מסך של תיבת הדו-שיח המיני ושל תיבת הדו-שיח המגה, בעיצוב בהיר ובעיצוב כהה.

לא תמיד, אבל בדרך כלל רכיבי תיבת הדו-שיח ישמשו לאיסוף חלק ממידע האינטראקציה. טפסים בתוך אלמנטים של תיבת דו-שיח מותאמים זה לזה. מומלץ להשתמש ברכיב טופס כדי לעטוף את תוכן תיבת הדו-שיח, כדי ש-JavaScript תהיה לה גישה לנתונים שהמשתמש הזין. בנוסף, לחצנים בתוך טופס שמשתמשים ב-method="dialog" יכולים לסגור תיבת דו-שיח בלי JavaScript ולהעביר נתונים.

<dialog id="MegaDialog" modal-mode="mega">
  <form method="dialog">
    …
    <button value="cancel">Cancel</button>
    <button value="confirm">Confirm</button>
  </form>
</dialog>

תיבת דו-שיח של Mega

בתיבת דו-שיח גדולה יש שלושה רכיבים בתוך הטופס: <header>, <article> ו-<footer>. הם משמשים כקונטיינרים סמנטיים וכיעדים של סגנון להצגת תיבת הדו-שיח. הכותרת מציגה את השם של חלון הדו-שיח ומציעה לחצן סגירה. המאמר הזה מיועד למידע ולנתונים שמוזנים בטופס. בכותרת התחתונה יש <menu> של לחצני פעולה.

<dialog id="MegaDialog" modal-mode="mega">
  <form method="dialog">
    <header>
      <h3>Dialog title</h3>
      <button onclick="this.closest('dialog').close('close')"></button>
    </header>
    <article>...</article>
    <footer>
      <menu>
        <button autofocus type="reset" onclick="this.closest('dialog').close('cancel')">Cancel</button>
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

לחצן התפריט הראשון כולל את הערך autofocus וכן onclick לטיפול באירועים בקוד. מאפיין autofocus יקבל את המיקוד כשתיבת הדו-שיח תיפתח, ולדעתי מומלץ להגדיר אותו ללחצן הביטול ולא ללחצן האישור. כך מוודאים שהאישור מבוצע בכוונה ולא בטעות.

תיבת דו-שיח קטנה

תיבת הדו-שיח המיני דומה מאוד לתיבת הדו-שיח המגה, רק שחסר בה רכיב <header>. כך הוא יכול להיות קטן יותר ולשמור על פורמט מדויק.

<dialog id="MiniDialog" modal-mode="mini">
  <form method="dialog">
    <article>
      <p>Are you sure you want to remove this user?</p>
    </article>
    <footer>
      <menu>
        <button autofocus type="reset" onclick="this.closest('dialog').close('cancel')">Cancel</button>
        <button type="submit" value="confirm">Confirm</button>
      </menu>
    </footer>
  </form>
</dialog>

רכיב תיבת הדו-שיח מספק בסיס חזק לרכיב של שדה ראייה מלא שיכול לאסוף נתונים ואינטראקציות של משתמשים. בעזרת הרכיבים הבסיסיים האלה תוכלו ליצור אינטראקציות מעניינות ויעילות באתר או באפליקציה.

נגישות

לאלמנט תיבת הדו-שיח יש נגישות מובנית טובה מאוד. במקום להוסיף את התכונות האלה כמו שאני עושה בדרך כלל, רבות מהן כבר קיימות.

שחזור המיקוד

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

ברכיב תיבת הדו-שיח, זוהי התנהגות ברירת המחדל המובנית:

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

נעילה של המיקוד

רכיב תיבת הדו-שיח ינהל בשבילכם את inert במסמך. לפני inert, המערכת השתמשה ב-JavaScript כדי לבדוק אם המיקוד עוזב רכיב, ובמקרה כזה היא מנתבת אותו בחזרה.

תמיכה בדפדפנים

  • Chrome: ‏ 102.
  • Edge: ‏ 102.
  • Firefox: ‏ 112.
  • Safari: 15.5.

מקור

אחרי inert, אפשר 'להקפיא' חלקים מהמסמך כך שהם לא יהיו יותר יעדי מיקוד או אינטראקטיביים באמצעות העכבר. במקום לגרום לכך שהמיקוד יישאר במקום אחד, הוא מועבר לחלק האינטראקטיבי היחיד במסמך.

פתיחת אלמנט והעברת המיקוד אליו באופן אוטומטי

כברירת מחדל, רכיב תיבת הדו-שיח מקצה את המיקוד לרכיב הראשון שניתן להתמקד בו בסימני התיוג של תיבת הדו-שיח. אם זה לא האלמנט הטוב ביותר שהמשתמש יוגדר אליו כברירת מחדל, תוכלו להשתמש במאפיין autofocus. כפי שתואר קודם, מומלץ להציג את ההודעה הזו על לחצן הביטול ולא על לחצן האישור. כך מוודאים שהאישור הוא מכוון ולא מקרי.

סגירה באמצעות מקש Escape

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

סגנונות

יש דרך פשוטה לעיצוב רכיב תיבת הדו-שיח ודרך קשה. כדי לבחור בדרך הקלה, לא משנים את מאפיין התצוגה של תיבת הדו-שיח ופועלים בהתאם למגבלות שלה. אני הולך בדרך הקשה כדי לספק אנימציות בהתאמה אישית לפתיחה ולסגירה של תיבת הדו-שיח, להשתלט על הנכס display ועוד.

עיצוב באמצעות Open Props

כדי לזרז את התאמת הצבעים ואת העקביות הכוללת של העיצוב, הכנסתי ללא בושה את ספריית המשתנים של CSS, Open Props. בנוסף למשתנים שסופקו בחינם, אני גם מייבא קובץ normalize וחלק מהלחצנים, ששניהם זמינים ב-Open Props כאפשרויות ייבוא אופציונליות. הייבוא הזה עוזר לי להתמקד בהתאמה אישית של תיבת הדו-שיח והדגמה, בלי צורך בהרבה סגנונות כדי לתמוך בהם ולהפוך אותם למרשימים.

עיצוב הרכיב <dialog>

הבעלות על נכס הרשת המדיה

ברירת המחדל של הצגה והסתרה של רכיב תיבת דו-שיח משנה את מאפיין התצוגה מ-block ל-none. לצערי, המשמעות היא שלא ניתן להוסיף אנימציה של כניסה ויציאה, אלא רק כניסה. אני רוצה להוסיף אנימציה לכניסה וליציאה, והשלב הראשון הוא להגדיר מאפיין display משלי:

dialog {
  display: grid;
}

כשמשנים את הערך של מאפיין התצוגה, וכך הופכים לבעלים שלו, כפי שמוצג בקטע הקוד של ה-CSS שלמעלה, צריך לנהל כמות משמעותית של סגנונות כדי לספק חוויית משתמש טובה. קודם כול, המצב שמוגדר כברירת מחדל של תיבת דו-שיח הוא סגור. אפשר לייצג את המצב הזה באופן חזותי ולמנוע מתיבה לקבל אינטראקציות באמצעות הסגנונות הבאים:

dialog:not([open]) {
  pointer-events: none;
  opacity: 0;
}

עכשיו תיבת הדו-שיח לא גלויה ולא ניתן לבצע בה פעולות כשהיא לא פתוחה. בהמשך אוסיף קצת JavaScript כדי לנהל את המאפיין inert בתיבת הדו-שיח, וכך לוודא שגם משתמשים במקלדת ובקורא מסך לא יוכלו להגיע לתיבת הדו-שיח המוסתרת.

הוספת ערכת צבעים דינמית לתיבת הדו-שיח

תיבת דו-שיח גדולה שבה מוצגים העיצוב הבהיר והעיצוב הכהה, עם הדגמה של צבעי המשטח.

כשמשתמשים ב-color-scheme, המערכת בוחרת עבור המסמך עיצוב צבע מותאם שמותאם להעדפות המערכת שלכם (בהיר או כהה). רציתי להתאים אישית את רכיב תיבת הדו-שיח בצורה רחבה יותר. ב-Open Props יש כמה צבעים של משטחים שמתאימים באופן אוטומטי להעדפות המערכת שלכם לגבי צבעים בהירים או כהים, בדומה לשימוש ב-color-scheme. הן נהדרות ליצירת שכבות בעיצוב, ואני אוהב להשתמש בצבע כדי לתמוך באופן חזותי במראה של פני השכבות. צבע הרקע הוא var(--surface-1). כדי להציב את התמונה מעל השכבה הזו, משתמשים ב-var(--surface-2):

dialog {
  
  background: var(--surface-2);
  color: var(--text-1);
}

@media (prefers-color-scheme: dark) {
  dialog {
    border-block-start: var(--border-size-1) solid var(--surface-3);
  }
}

בהמשך נוסיף עוד צבעים דינמיים לרכיבי הצאצאים, כמו הכותרת והכותרת התחתונה. אני מתייחס אליהם כאל אלמנט נוסף של תיבת דו-שיח, אבל הם חשובים מאוד בתכנון של תיבת דו-שיח מעניינת ומעוצבת היטב.

התאמת הגודל של תיבת הדו-שיח

כברירת מחדל, תיבת הדו-שיח מקצה את הגודל שלה לתוכן שלה, וזה בדרך כלל מצוין. המטרה שלי כאן היא להגביל את max-inline-size לגודל קריא (--size-content-3 = 60ch) או ל-90% מרוחב אזור התצוגה. כך תוכלו לוודא שהתיבת הדו-שיח לא תתפוס את כל המסך במכשיר נייד, ולא תהיה רחבה מדי במסך מחשב כך שיהיה קשה לקרוא אותה. לאחר מכן מוסיפים את הערך max-block-size כדי שתיבת הדו-שיח לא תחרוג מהגובה של הדף. המשמעות היא גם שנצטרך לציין איפה נמצא האזור שניתן לגלילה בתיבת הדו-שיח, במקרה שמדובר ברכיב תיבת דו-שיח גבוה.

dialog {
  
  max-inline-size: min(90vw, var(--size-content-3));
  max-block-size: min(80vh, 100%);
  max-block-size: min(80dvb, 100%);
  overflow: hidden;
}

שמתם לב ש-max-block-size מופיע פעמיים? בקוד הראשון נעשה שימוש ב-80vh, יחידה פיזית של אזור התצוגה. מה שאני רוצה באמת הוא לשמור על הזרימה היחסית של התיבת הדו-שיח למשתמשים בינלאומיים, ולכן אני משתמש ביחידת dvb, שהיא לוגית, חדשה יותר ונתמכת רק באופן חלקי, בהצהרה השנייה, עד שהיא תהיה יציבה יותר.

מיקום תיבת הדו-שיח של Mega

כדי לסייע במיקום של רכיב תיבת דו-שיח, כדאי לפרק אותו לשני חלקים: הרקע במסך מלא ומאגר תיבת הדו-שיח. תמונת הרקע חייבת לכסות את כל מה שמופיע בתמונה, וליצור אפקט הצללה כדי להדגיש שהתיבת הדו-שיח הזו נמצאת בחזית, ושלא ניתן לגשת לתוכן שמאחור. מאגר הדו-שיח יכול להתמקם במרכז הרקע הזה ולקבוע את הצורה שלו בהתאם לתוכן.

הסגנונות הבאים מאפשרים לקבוע את מיקום רכיב תיבת הדו-שיח בחלון, למתוח אותו לכל פינה ולהשתמש ב-margin: auto כדי למרכז את התוכן:

dialog {
  
  margin: auto;
  padding: 0;
  position: fixed;
  inset: 0;
  z-index: var(--layer-important);
}
סגנונות של תיבות דו-שיח גדולות לנייד

בחלונות צפייה קטנים, אני משתמש בסגנון שונה מעט למגה מודאל הזה של דף מלא. הגדרתי את השוליים התחתונים לערך 0, כך שתוכן תיבת הדו-שיח יופיע בתחתית אזור התצוגה. בעזרת כמה שינויים בסגנון, אפשר להפוך את תיבת הדו-שיח ל-actionsheet, קרוב יותר לאצבעות המשתמש:

@media (max-width: 768px) {
  dialog[modal-mode="mega"] {
    margin-block-end: 0;
    border-end-end-radius: 0;
    border-end-start-radius: 0;
  }
}

צילום מסך של devtools שמכסה את המרווחים בין השוליים 
  בתיבת הדו-שיח המגה במחשב ובנייד בזמן שהיא פתוחה.

מיקום של תיבת דו-שיח קטנה

כשמשתמשים בחלון תצוגה גדול יותר, כמו במחשב, בחרתי למקם את תיבת הדו-שיח המיני מעל האלמנט שהפעיל אותן. כדי לעשות את זה, דרושה לי JavaScript. הטכניקה שבה אני משתמש מפורטת כאן, אבל היא לא רלוונטית למאמר הזה. בלי JavaScript, תיבת הדו-שיח המיני מופיעה במרכז המסך, בדיוק כמו תיבת הדו-שיח המגה.

איך להפוך את התמונות למיוחדות

לבסוף, מוסיפים קצת סטייל לתיבת הדו-שיח כך שתראה כמו משטח רך שנמצא הרבה מעל הדף. כדי ליצור את המראה הרך, הפינות של תיבת הדו-שיח מעוגלות. כדי ליצור את העומק, השתמשו באחד מנכסי הצללים שנוצרו בקפידה על ידי Open Props:

dialog {
  
  border-radius: var(--radius-3);
  box-shadow: var(--shadow-6);
}

התאמה אישית של פסאודו-הרכיב backdrop

בחרתי להשתמש ברקע בצורה מאוד עדינה, והוספתי רק אפקט טשטוש באמצעות backdrop-filter לתיבת הדו-שיח הגדולה:

תמיכה בדפדפנים

  • Chrome: ‏ 76.
  • Edge: ‏ 79.
  • Firefox: ‏ 103.
  • Safari: ‏ 18.

מקור

dialog[modal-mode="mega"]::backdrop {
  backdrop-filter: blur(25px);
}

בנוסף, בחרתי להוסיף מעבר ל-backdrop-filter, בתקווה שבעתיד הדפדפנים יאפשרו מעבר של אלמנט הרקע:

dialog::backdrop {
  transition: backdrop-filter .5s ease;
}

צילום מסך של תיבת הדו-שיח הגדולה, שמופיעה על גבי רקע מטושטש של דמויות פרופיל צבעוניות.

תוספות לעיצוב

אני קורא לקטע הזה 'תכונות נוספות' כי הוא קשור יותר לדמו של רכיב תיבת הדו-שיח שלי מאשר לרכיב תיבת הדו-שיח באופן כללי.

גלילה בתוך מאגר

כשתיבת הדו-שיח מוצגת, המשתמש עדיין יכול לגלול בדף שמאחוריה, ואני לא רוצה את זה:

בדרך כלל, overscroll-behavior הוא הפתרון הרגיל שלי, אבל לפי המפרט, אין לו השפעה על תיבת הדו-שיח כי הוא לא יציאת גלילה, כלומר הוא לא גלילה, ולכן אין מה למנוע. אפשר להשתמש ב-JavaScript כדי לחפש את האירועים החדשים שמפורטים במדריך הזה, כמו 'closed' ו-'opened', ולהפעיל או להשבית את overflow: hidden במסמך. לחלופין, אפשר להמתין עד ש-:has() יהיה יציב בכל הדפדפנים:

תמיכה בדפדפנים

  • Chrome: ‏ 105.
  • Edge: ‏ 105.
  • Firefox: 121.
  • Safari: 15.4.

מקור

html:has(dialog[open][modal-mode="mega"]) {
  overflow: hidden;
}

עכשיו, כשתיבת דו-שיח גדולה פתוחה, במסמך ה-HTML מופיע overflow: hidden.

הפריסה של <form>

בנוסף לכך שהוא רכיב חשוב מאוד לאיסוף פרטי האינטראקציה מהמשתמש, אני משתמש בו כאן כדי למקם את הכותרת, הכותרת התחתונה ואת רכיבי המאמר. באמצעות הפריסה הזו, אני רוצה להציג את הצאצא של המאמר כאזור שניתן לגלילה. אני משיג את זה באמצעות grid-template-rows. רכיב המאמר מקבל את הערך 1fr, והטופס עצמו מוגבל לגובה המקסימלי של רכיב תיבת הדו-שיח. הגדרת הגובה הקבוע והגודל הקבוע של השורה מאפשרת להגביל את רכיב המאמר ולגלול אותו כשהתוכן חורג מהגודל:

dialog > form {
  display: grid;
  grid-template-rows: auto 1fr auto;
  align-items: start;
  max-block-size: 80vh;
  max-block-size: 80dvb;
}

צילום מסך של devtools עם שכבת-על של פרטי הפריסה של הרשת מעל השורות.

עיצוב תיבת הדו-שיח <header>

התפקיד של הרכיב הזה הוא לספק שם לתוכן של תיבת הדו-שיח ולהציע לחצן סגירה שקל למצוא. הוא מקבל גם צבע פני שטח כדי שייראה כאילו הוא מאחורי תוכן המאמר בתיבת הדו-שיח. הדרישות האלה מובילות לשימוש בקונטיינר של flexbox, בפריטים שמסודרים אנכית עם רווחים בין הקצוות שלהם, וברווחים קטנים כדי לתת מקום לשם וללחצני הסגירה:

dialog > form > header {
  display: flex;
  gap: var(--size-3);
  justify-content: space-between;
  align-items: flex-start;
  background: var(--surface-2);
  padding-block: var(--size-3);
  padding-inline: var(--size-5);
}

@media (prefers-color-scheme: dark) {
  dialog > form > header {
    background: var(--surface-1);
  }
}

צילום מסך של כלי הפיתוח ל-Chrome עם שכבת-על של פרטי הפריסה של flexbox בכותרת של תיבת הדו-שיח.

עיצוב לחצן הסגירה של הכותרת

מכיוון שהדגמה משתמשת בלחצני Open Props, לחצן הסגירה מותאם אישית לחצן עגול עם סמל במרכז, כך:

dialog > form > header > button {
  border-radius: var(--radius-round);
  padding: .75ch;
  aspect-ratio: 1;
  flex-shrink: 0;
  place-items: center;
  stroke: currentColor;
  stroke-width: 3px;
}

צילום מסך של כלי הפיתוח של Chrome עם שכבת-על של מידע על הגודל והרווחים של לחצן הסגירה בכותרת.

עיצוב תיבת הדו-שיח <article>

לרכיב המאמר יש תפקיד מיוחד בתיבת הדו-שיח הזו: זהו מקום שמיועד לגלילה במקרה של תיבת דו-שיח גבוהה או ארוכה.

כדי לעשות זאת, רכיב הטופס של ההורה הגדיר כמה ערכי מקסימום לעצמו, שמספקים אילוצים לרכיב המאמר הזה אם הוא ארוך מדי. מגדירים את overflow-y: auto כך שפסורי גלילה יוצגו רק כשצריך, יכילו גלילה בתוך הפסור באמצעות overscroll-behavior: contain, והשאר יהיו סגנונות תצוגה מותאמים אישית:

dialog > form > article {
  overflow-y: auto; 
  max-block-size: 100%; /* safari */
  overscroll-behavior-y: contain;
  display: grid;
  justify-items: flex-start;
  gap: var(--size-3);
  box-shadow: var(--shadow-2);
  z-index: var(--layer-1);
  padding-inline: var(--size-5);
  padding-block: var(--size-3);
}

@media (prefers-color-scheme: light) {
  dialog > form > article {
    background: var(--surface-1);
  }
}

התפקיד של הכותרת התחתונה הוא להכיל תפריטים של לחצני פעולה. הקוד של Flexbox משמש ליישור התוכן לקצה הציר של הכותרת התחתונה, ולאחר מכן להוספת רווח כדי לתת לחצנים מקום.

dialog > form > footer {
  background: var(--surface-2);
  display: flex;
  flex-wrap: wrap;
  gap: var(--size-3);
  justify-content: space-between;
  align-items: flex-start;
  padding-inline: var(--size-5);
  padding-block: var(--size-3);
}

@media (prefers-color-scheme: dark) {
  dialog > form > footer {
    background: var(--surface-1);
  }
}

צילום מסך של כלי הפיתוח של Chrome עם שכבת-על של מידע על הפריסה של flexbox ברכיב הכותרת התחתונה.

האלמנט menu משמש להכיל את לחצני הפעולה של תיבת הדו-שיח. הקוד משתמש בפריסה של flexbox עם gap כדי ליצור רווח בין הלחצנים. לרכיבי התפריט יש ריפוד, כמו <ul>. אני גם מסיר את הסגנון הזה כי אין לי צורך בו.

dialog > form > footer > menu {
  display: flex;
  flex-wrap: wrap;
  gap: var(--size-3);
  padding-inline-start: 0;
}

dialog > form > footer > menu:only-child {
  margin-inline-start: auto;
}

צילום מסך של כלי הפיתוח של Chrome עם שכבת-על של מידע על flexbox על אלמנטים בתפריט הכותרת התחתונה.

Animation

לרוב, אלמנטים של תיבת דו-שיח כוללים אנימציה כי הם נכנסים ויוצאים מהחלון. כדאי להוסיף תנועה תומכת לתיבת הדו-שיח כדי להקל על המשתמשים להבין את התהליך.

בדרך כלל אפשר להוסיף אנימציה לרכיב תיבת הדו-שיח רק כשהיא נפתחת, ולא כשהיא נסגרת. הסיבה לכך היא שהדפדפן מפעיל או משבית את המאפיין display ברכיב. בעבר, המדריך הגדיר את התצוגה כמרקע, ואף פעם לא הגדיר אותה כ'ללא'. כך תוכלו להוסיף אנימציה של כניסה ויציאה.

ב-Open Props יש הרבה אנימציות של נקודות מפתח לשימוש, שמאפשרות לבצע תזמור בקלות ובאופן ברור. הנה היעדים של האנימציה והגישה של שכבות שבחרתי:

  1. תנועה מופחתת היא מעבר ברירת המחדל, מעבר פשוט של עמעום והבהרה של האטימות.
  2. אם התנועה תקינה, המערכת תוסיף אנימציות של החלקה ושל שינוי קנה מידה.
  3. הפריסה הרספונסיבית לנייד של תיבת הדו-שיח המגה מותאמת להצגה בתנועה החוצה.

מעבר בטוח ומשמעותי לברירת המחדל

Open Props כולל פריימים מרכזיים להוספת מעברים של דהייה, אבל אני מעדיף את הגישה הזו של מעברים בשכבות כברירת מחדל, עם אנימציות של פריימים מרכזיים כשדרוגים פוטנציאליים. קודם כבר הגדרנו את הסגנון של הרשאות הגישה לתיבת הדו-שיח באמצעות שקיפות, והגדרתם את הערך של 1 או 0 בהתאם למאפיין [open]. כדי לעבור בין 0% ל-100%, צריך לציין לדפדפן את משך ההעברה ואת סוג ההעברה:

dialog {
  transition: opacity .5s var(--ease-3);
}

הוספת תנועה למעבר

אם המשתמש מסכים לתנועה, הן תיפתחנה בתנועה למעלה והן יסגרו בתנועה לצדדים. אפשר לעשות זאת באמצעות שאילתה של מדיה prefers-reduced-motion וכמה נכסי Open Props:

@media (prefers-reduced-motion: no-preference) {
  dialog {
    animation: var(--animation-scale-down) forwards;
    animation-timing-function: var(--ease-squish-3);
  }

  dialog[open] {
    animation: var(--animation-slide-in-up) forwards;
  }
}

התאמה של אנימציית היציאה לניידים

קודם, בקטע של העיצוב, ציינו שהסגנון של תיבת הדו-שיח המגה מותאם למכשירים ניידים כך שייראה יותר כמו גיליון פעולות, כאילו פיסת נייר קטנה החליקה למעלה מחלקו התחתון של המסך ועדיין מחוברת לתחתית. אנימציית היציאה עם ההקטנה לא מתאימה לעיצוב החדש, ואפשר להתאים אותה באמצעות כמה שאילתות מדיה ונכסי Open Props:

@media (prefers-reduced-motion: no-preference) and @media (max-width: 768px) {
  dialog[modal-mode="mega"] {
    animation: var(--animation-slide-out-down) forwards;
    animation-timing-function: var(--ease-squish-2);
  }
}

JavaScript

יש הרבה דברים שאפשר להוסיף באמצעות JavaScript:

// dialog.js
export default async function (dialog) {
  // add light dismiss
  // add closing and closed events
  // add opening and opened events
  // add removed event
  // removing loading attribute
}

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

הוספת סגירה באמצעות תאורה

המשימה הזו פשוטה והיא תוספת נהדרת לרכיב של תיבת דו-שיח שלא מופיעה בו אנימציה. האינטראקציה מתבצעת על ידי מעקב אחר קליקים על רכיב התיבת הדו-שיח, ושימוש בהעברת אירועים (event bubbling) כדי להעריך על מה לחצו. האינטראקציה תתבצע רק close() אם זהו הרכיב העליון:

export default async function (dialog) {
  dialog.addEventListener('click', lightDismiss)
}

const lightDismiss = ({target:dialog}) => {
  if (dialog.nodeName === 'DIALOG')
    dialog.close('dismiss')
}

הודעה dialog.close('dismiss'). האירוע נקרא ומחרוזת מסוימת מסופקת. אפשר לאחזר את המחרוזת הזו באמצעות קוד JavaScript אחר כדי לקבל תובנות לגבי האופן שבו תיבת הדו-שיח נסגרה. צירפתי גם מחרוזות סגירה בכל פעם שאקרא לפונקציה מכפתורים שונים, כדי לספק לאפליקציה הקשר לגבי האינטראקציה של המשתמש.

הוספת אירועים סגורים ואירועים שנסגרו

רכיב תיבת הדו-שיח מגיע עם אירוע סגירה: הוא מופעל באופן מיידי כשפונקציית תיבת הדו-שיח close() נקראת. מכיוון שאנחנו יוצרים אנימציה לאלמנט הזה, כדאי להגדיר אירועים לפני ואחרי האנימציה, כדי לאפשר איסוף נתונים או איפוס של טופס תיבת הדו-שיח. כאן משתמשים בה כדי לנהל את ההוספה של המאפיין inert בתיבת הדו-שיח הסגורה, ובדמו משתמשים בהם כדי לשנות את רשימת התמונות של הדמות אם המשתמש שלח תמונה חדשה.

כדי לעשות זאת, יוצרים שני אירועים חדשים בשם closing ו-closed. לאחר מכן, מקשיבים לאירוע הסגירה המובנה בתיבת הדו-שיח. מכאן, מגדירים את תיבת הדו-שיח ל-inert ושולחים את האירוע closing. המשימה הבאה היא להמתין לסיום ההפעלה של האנימציות והמעברים בתיבת הדו-שיח, ואז לשלוח את האירוע closed.

const dialogClosingEvent = new Event('closing')
const dialogClosedEvent  = new Event('closed')

export default async function (dialog) {
  
  dialog.addEventListener('close', dialogClose)
}

const dialogClose = async ({target:dialog}) => {
  dialog.setAttribute('inert', '')
  dialog.dispatchEvent(dialogClosingEvent)

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

const animationsComplete = element =>
  Promise.allSettled(
    element.getAnimations().map(animation => 
      animation.finished))

הפונקציה animationsComplete, שמשמשת גם ביצירת רכיב של הודעה קופצת, מחזירה הבטחה על סמך השלמת ההבטחות של האנימציה והמעבר. לכן, dialogClose היא פונקציה אסינכררונית. היא יכולה await את ההבטחה שהוחזרה ולהמשיך בבטחה לאירוע הסגור.

הוספת אירועים פתוחים ואירועים שעתיד להיפתח

קשה יותר להוסיף את האירועים האלה כי אלמנט תיבת הדו-שיח המובנה לא מספק אירוע פתיחה, כמו שהוא מספק אירוע סגירה. אני משתמש ב-MutationObserver כדי לספק תובנות לגבי השינויים במאפיינים של תיבת הדו-שיח. במעקב הזה, אבדוק שינויים במאפיין open ואנהל את האירועים בהתאמה אישית בהתאם.

בדומה לאופן שבו יצרנו את האירועים 'סגירה' ו'סגור', יוצרים שני אירועים חדשים בשם opening ו-opened. במקום להאזין לאירוע הסגירה של תיבת הדו-שיח, הפעם משתמשים במעקב אחרי מוטציות שנוצרו כדי לעקוב אחרי המאפיינים של תיבת הדו-שיח.


const dialogOpeningEvent = new Event('opening')
const dialogOpenedEvent  = new Event('opened')

export default async function (dialog) {
  
  dialogAttrObserver.observe(dialog, { 
    attributes: true,
  })
}

const dialogAttrObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(async mutation => {
    if (mutation.attributeName === 'open') {
      const dialog = mutation.target

      const isOpen = dialog.hasAttribute('open')
      if (!isOpen) return

      dialog.removeAttribute('inert')

      // set focus
      const focusTarget = dialog.querySelector('[autofocus]')
      focusTarget
        ? focusTarget.focus()
        : dialog.querySelector('button').focus()

      dialog.dispatchEvent(dialogOpeningEvent)
      await animationsComplete(dialog)
      dialog.dispatchEvent(dialogOpenedEvent)
    }
  })
})

פונקציית הקריאה החוזרת של משתנה המעקב אחרי המוטציות תופעל כשמאפייני החלון יוחלפו, ותספק את רשימת השינויים כמערך. בודקים את השינויים במאפיינים ומחפשים אם attributeName פתוח. בשלב הבא, בודקים אם לרכיב יש את המאפיין או לא: זה מאפשר לדעת אם תיבת הדו-שיח נפתחה או לא. אם היא נפתחה, מסירים את המאפיין inert, מעבירים את המיקוד לרכיב שמבקש את autofocus או לרכיב button הראשון שנמצא בתיבת הדו-שיח. לבסוף, בדומה לאירוע הסגירה ולאירוע הסגור, שולחים את אירוע הפתיחה מיד, ממתינים לסיום האנימציות ואז שולחים את אירוע הפתיחה.

הוספת אירוע שהוסר

באפליקציות עם דף יחיד, לרוב מוסיפים ומסירים תיבות דו-שיח על סמך נתיבים או צרכים ומצבים אחרים של האפליקציה. כדאי לנקות אירועים או נתונים כשמסירים תיבת דו-שיח.

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


const dialogRemovedEvent = new Event('removed')

export default async function (dialog) {
  
  dialogDeleteObserver.observe(document.body, {
    attributes: false,
    subtree: false,
    childList: true,
  })
}

const dialogDeleteObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(removedNode => {
      if (removedNode.nodeName === 'DIALOG') {
        removedNode.removeEventListener('click', lightDismiss)
        removedNode.removeEventListener('close', dialogClose)
        removedNode.dispatchEvent(dialogRemovedEvent)
      }
    })
  })
})

פונקציית ה-callback של משקיף המוטציות נקראת בכל פעם שמתווספים או מוסרים צאצאים לגוף המסמך. המוטציות הספציפיות שנמצאות במעקב הן של removedNodes עם הערך nodeName של תיבת דו-שיח. אם תיגרם הסרה של תיבת דו-שיח, האירועים של הלחיצה והסגירה יוסרו כדי לפנות מקום בזיכרון, והאירוע המותאם אישית של ההסרה יישלח.

הסרת מאפיין הטעינה

כדי למנוע את הפעלת אנימציית היציאה של תיבת הדו-שיח כשהיא מתווספת לדף או בזמן טעינת הדף, הוספנו למאפיין הטעינה של תיבת הדו-שיח. הסקריפט הבא ממתין לסיום ההפעלה של האנימציות של תיבת הדו-שיח, ואז מסיר את המאפיין. עכשיו אפשר להציג את תיבת הדו-שיח עם אנימציה של כניסה ויציאה, וכך להסתיר אנימציה שמסיחה את הדעת.

export default async function (dialog) {
  
  await animationsComplete(dialog)
  dialog.removeAttribute('loading')
}

מידע נוסף על הבעיה של מניעת אנימציות של נקודות מפתח בטעינת הדף

בסך הכול

אחרי שהסברנו כל קטע בנפרד, הנה dialog.js במלואו:

// custom events to be added to <dialog>
const dialogClosingEvent = new Event('closing')
const dialogClosedEvent  = new Event('closed')
const dialogOpeningEvent = new Event('opening')
const dialogOpenedEvent  = new Event('opened')
const dialogRemovedEvent = new Event('removed')

// track opening
const dialogAttrObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(async mutation => {
    if (mutation.attributeName === 'open') {
      const dialog = mutation.target

      const isOpen = dialog.hasAttribute('open')
      if (!isOpen) return

      dialog.removeAttribute('inert')

      // set focus
      const focusTarget = dialog.querySelector('[autofocus]')
      focusTarget
        ? focusTarget.focus()
        : dialog.querySelector('button').focus()

      dialog.dispatchEvent(dialogOpeningEvent)
      await animationsComplete(dialog)
      dialog.dispatchEvent(dialogOpenedEvent)
    }
  })
})

// track deletion
const dialogDeleteObserver = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    mutation.removedNodes.forEach(removedNode => {
      if (removedNode.nodeName === 'DIALOG') {
        removedNode.removeEventListener('click', lightDismiss)
        removedNode.removeEventListener('close', dialogClose)
        removedNode.dispatchEvent(dialogRemovedEvent)
      }
    })
  })
})

// wait for all dialog animations to complete their promises
const animationsComplete = element =>
  Promise.allSettled(
    element.getAnimations().map(animation => 
      animation.finished))

// click outside the dialog handler
const lightDismiss = ({target:dialog}) => {
  if (dialog.nodeName === 'DIALOG')
    dialog.close('dismiss')
}

const dialogClose = async ({target:dialog}) => {
  dialog.setAttribute('inert', '')
  dialog.dispatchEvent(dialogClosingEvent)

  await animationsComplete(dialog)

  dialog.dispatchEvent(dialogClosedEvent)
}

// page load dialogs setup
export default async function (dialog) {
  dialog.addEventListener('click', lightDismiss)
  dialog.addEventListener('close', dialogClose)

  dialogAttrObserver.observe(dialog, { 
    attributes: true,
  })

  dialogDeleteObserver.observe(document.body, {
    attributes: false,
    subtree: false,
    childList: true,
  })

  // remove loading attribute
  // prevent page load @keyframes playing
  await animationsComplete(dialog)
  dialog.removeAttribute('loading')
}

שימוש במודול dialog.js

הפונקציה המיוצאת מהמודול אמורה להיקרא ולהעביר אליה רכיב תיבת דו-שיח שרוצים להוסיף אליו את האירועים והפונקציונליות החדשים האלה:

import GuiDialog from './dialog.js'

const MegaDialog = document.querySelector('#MegaDialog')
const MiniDialog = document.querySelector('#MiniDialog')

GuiDialog(MegaDialog)
GuiDialog(MiniDialog)

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

האזנה לאירועים המותאמים אישית החדשים

כל רכיב של תיבת דו-שיח משודרגת יכול עכשיו להאזין לחמישה אירועים חדשים, כך:

MegaDialog.addEventListener('closing', dialogClosing)
MegaDialog.addEventListener('closed', dialogClosed)

MegaDialog.addEventListener('opening', dialogOpening)
MegaDialog.addEventListener('opened', dialogOpened)

MegaDialog.addEventListener('removed', dialogRemoved)

לפניכם שתי דוגמאות לטיפול באירועים האלה:

const dialogOpening = ({target:dialog}) => {
  console.log('Dialog opening', dialog)
}

const dialogClosed = ({target:dialog}) => {
  console.log('Dialog closed', dialog)
  console.info('Dialog user action:', dialog.returnValue)

  if (dialog.returnValue === 'confirm') {
    // do stuff with the form values
    const dialogFormData = new FormData(dialog.querySelector('form'))
    console.info('Dialog form data', Object.fromEntries(dialogFormData.entries()))

    // then reset the form
    dialog.querySelector('form')?.reset()
  }
}

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

ההודעה dialog.returnValue: היא מכילה את מחרוזת הסגירה שמועברת כשמתבצעת קריאה לאירוע close() של תיבת הדו-שיח. חשוב לדעת אם תיבת הדו-שיח נסגרה, בוטלה או אושרה באירוע dialogClosed. אם הוא מאושר, הסקריפט אוסף את ערכי הטופס ומאפס את הטופס. האיפוס שימושי כדי שכשתופיע שוב תיבת הדו-שיח, היא תהיה ריקה ומוכנה לשליחה חדשה.

סיכום

עכשיו, אחרי שסיפרתי לך איך עשיתי את זה, איך היית עושה את זה? 🙂

נרחיב את הגישות שלנו ונלמד את כל הדרכים לפיתוח באינטרנט.

אתם יכולים ליצור גרסת דמו, לשלוח לי קישורים בטוויטר ואוסיף אותה לקטע 'רמיקסים של הקהילה' שבהמשך.

רמיקסים של הקהילה

משאבים