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

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

בפוסט הזה אני רוצה לשתף את המחשבות שלי לגבי יצירה של מודלים בגודל מיני ומגה-גובה שמתאימים לצבעים, רספונסיביים ונגישים עם האלמנט <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 ורכיב handler מוטבע של אירועים ב-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. לצערנו זה אומר שאי אפשר להציג אנימציה בפנים ובחוץ, רק ב-YouTube. אני רוצה להוסיף אנימציה לכניסה וליציאה, והשלב הראשון הוא להגדיר מאפיין 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, שהיא לוגית, חדשה יותר ונתמכת רק באופן חלקי, בהצהרה השנייה, עד שהיא תהיה יציבה יותר.

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

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

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

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

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

@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.
  • קצה: 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);
  }
}

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

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

בהדגמה נעשה שימוש בלחצני 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;
  }
}

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

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

@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
}

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

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

המשימה הזו פשוטה ומהווה תוספת נהדרת לרכיב של תיבת דו-שיח שלא מופיעה בו אנימציה. האינטראקציה מתאפשרת על ידי צפייה בקליקים על רכיב הדו-שיח ושימוש בבועות של אירוע כדי לבדוק על מה המשתמש לחץ, והמערכת תסתמך על 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. אם הוא מאושר, הסקריפט אוסף את ערכי הטופס ומאפס את הטופס. האיפוס שימושי כדי שכשתופיע שוב תיבת הדו-שיח, היא תהיה ריקה ומוכנה לשליחה חדשה.

סיכום

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

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

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

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

משאבים