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

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

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

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

אם ברצונך ליצור סרטון, הנה גרסת YouTube של הפוסט הזה:

סקירה כללית

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

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

תמיכה בדפדפן

  • 37
  • 79
  • 98
  • 15.4

מקור

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

Markup

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

<dialog>
  …
</dialog>

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

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

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

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

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

תיבת דו-שיח ענקית

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

תיבת דו-שיח קצרה

תיבת הדו-שיח מסוג Mini דומה מאוד לתיבת הדו-שיח הגדולה, רק חסר בה רכיב <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 כדי להתמקד בעזיבת רכיב, ובשלב הזה הוא מיירט ומחזיר אותו.

תמיכה בדפדפן

  • 102
  • 102
  • 112
  • 15.5

מקור

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

פתיחה של רכיב והתמקדות אוטומטית בו

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

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

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

סגנונות

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

עיצוב עם אביזרים פתוחים

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

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

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

הסגנונות הבאים מתקנים את הרכיב של תיבת הדו-שיח בחלון, מותחים אותו לכל פינה ומשתמשים ב-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;
  }
}

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

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

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

בולט לעין

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

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

התאמה אישית של רכיב הפסאודו של הרקע

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

תמיכה בדפדפן

  • 76
  • 17
  • 103
  • 9

מקור

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

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

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

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

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

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

גבול גלילה

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

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

תמיכה בדפדפן

  • 105
  • 105
  • 121
  • 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;
}

צילום מסך של כלי הפיתוח שמציגים את הפרטים של פריסת הרשת מעל השורות.

עיצוב תיבת הדו-שיח <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 Devtools שמוצג כשכבת-על של פרטי פריסת 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 Devtools עבור לחצן סגירת הכותרת.

עיצוב תיבת הדו-שיח <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);
  }
}

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

הרכיב menu משמש להכיל את לחצני הפעולה בתיבת הדו-שיח. נעשה בו שימוש בפריסת אריזה גמישה עם 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 Devtools שמוצגים כשכבת-על ברכיבי התפריט בכותרת התחתונה.

Animation

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

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

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

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

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

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

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

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

אם למשתמש יש תנועות זהות, גם תיבת הדו-שיח עם הגודל 'מגה' וגם תיבת הדו-שיח של המיני אמורה להחליק כלפי מעלה ככניסה, ולהתרחב כלפי חוץ כיציאה. תוכלו לעשות זאת באמצעות שאילתת המדיה prefers-reduced-motion וכמה 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
}

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

הוספת נורה מסוג 'סגירה'

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

בדומה לאופן שבו התחלנו את אירועי הסגירה והסגירה, כדאי ליצור שני אירועים חדשים בשם 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)
    }
  })
})

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

סיכום

עכשיו, אחרי שאת יודעת איך עשיתי את זה, איך היית רוצה ‽ 🙂

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

צור הדגמה (דמו), ציוץ לי קישורים ואני אוסיף אותה לקטע 'רמיקסים של הקהילה' למטה!

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

משאבים