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

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

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

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

תמיכה בדפדפן

  • 102
  • 102
  • 112
  • 15.5

מקור

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

פתיחת אלמנט ומיקוד אוטומטי שלו

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

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

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

סגנונות

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

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

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

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

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

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

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
  • 79
  • 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);
  }
}

צילום מסך של פרטי פריסת 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);
  }
}

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

הרכיב 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 Devtools שיוצר שכבת-על של מידע על 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 וכמה אפליקציות פתוחות:

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

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

סיכום

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

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

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

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

מקורות מידע