בניית רכיב מתג

סקירה כללית בסיסית של תהליך היצירה של רכיב מתג נגישות ורספונסיבי.

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

הדגמה

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

סקירה כללית

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

הדמו הזה משתמש ב-<input type="checkbox" role="switch"> לרוב הפונקציות שלו, והיתרון של השימוש ב-<input type="checkbox" role="switch"> הוא שאין צורך ב-CSS או ב-JavaScript כדי שהדמו יהיה פונקציונלי ונגיש. טעינת CSS מאפשרת תמיכה בשפות שמיועדות לקריאה מימין לשמאל, בפורמט אנכי, באנימציה ועוד. טעינת JavaScript מאפשרת לגרור את המתג ולהפעיל אותו.

מאפיינים מותאמים אישית

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

מעקב

האורך (--track-size), המרווח הפנימי ושני צבעים:

.gui-switch {
  --track-size: calc(var(--thumb-size) * 2);
  --track-padding: 2px;

  --track-inactive: hsl(80 0% 80%);
  --track-active: hsl(80 60% 45%);

  --track-color-inactive: var(--track-inactive);
  --track-color-active: var(--track-active);

  @media (prefers-color-scheme: dark) {
    --track-inactive: hsl(80 0% 35%);
    --track-active: hsl(80 60% 60%);
  }
}

תמונות ממוזערות

הגודל, צבע הרקע וצבעי האינטראקציה מדגישים:

.gui-switch {
  --thumb-size: 2rem;
  --thumb: hsl(0 0% 100%);
  --thumb-highlight: hsl(0 0% 0% / 25%);

  --thumb-color: var(--thumb);
  --thumb-color-highlight: var(--thumb-highlight);

  @media (prefers-color-scheme: dark) {
    --thumb: hsl(0 0% 5%);
    --thumb-highlight: hsl(0 0% 100% / 25%);
  }
}

תנועה מופחתת

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

@custom-media --motionOK (prefers-reduced-motion: no-preference);

Markup

בחרתי לעטוף את הרכיב <input type="checkbox" role="switch"> ב-<label>, כדי לארוז את הקשר ביניהם ולמנוע אי-בהירות לגבי השיוך של התיבה לתיוג, תוך מתן אפשרות למשתמש ליצור אינטראקציה עם התווית כדי להחליף את מצב הקלט.

תווית ותיבת סימון טבעיות ולא מעוצבות.

<label for="switch" class="gui-switch">
  Label text
  <input type="checkbox" role="switch" id="switch">
</label>

<input type="checkbox"> מגיע עם API ומצב מובנים. הדפדפן מנהל את המאפיין checked ואת אירועי הקלט כמו oninput ו-onchanged.

פריסות

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

.gui-switch

הפריסה ברמה העליונה של המתג היא Flexbox. הכיתה .gui-switch מכילה את המאפיינים המותאמים אישית הפרטיים והציבוריים שבהם הצאצאים משתמשים כדי לחשב את הפריסות שלהם.

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

.gui-switch {
  display: flex;
  align-items: center;
  gap: 2ch;
  justify-content: space-between;
}

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

כלי הפיתוח של Flexbox שכבת-על של תווית ומתג אנכיים.

<label for="light-switch" class="gui-switch" style="flex-direction: column">
  Default
  <input type="checkbox" role="switch" id="light-switch">
</label>

מעקב

כדי להגדיר את הקלט של תיבת הסימון כמסלול של מתג, מסירים את הערך הרגיל של appearance: checkbox ומספקים לו גודל משלו:

כלי הפיתוח של Grid שכבת-על של המסלול למעבר, שמוצגים בו אזורים של מסלולי רשת עם השם &#39;track&#39;.

.gui-switch > input {
  appearance: none;

  inline-size: var(--track-size);
  block-size: var(--thumb-size);
  padding: var(--track-padding);

  flex-shrink: 0;
  display: grid;
  align-items: center;
  grid: [track] 1fr / [track] 1fr;
}

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

תמונות ממוזערות

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

האגודל הוא פסאודו-צאצא שמחובר ל-input[type="checkbox"] ונתפס על גבי הטראק במקום מתחתיו על ידי הצהרה על אזור הרשת track:

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

.gui-switch > input::before {
  content: "";
  grid-area: track;
  inline-size: var(--thumb-size);
  block-size: var(--thumb-size);
}

סגנונות

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

השוואה בין העיצוב הבהיר והעיצוב הכהה של המתג לבין המצבים שלו.

סגנונות אינטראקציה במגע

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

.gui-switch {
  cursor: pointer;
  user-select: none;
  -webkit-tap-highlight-color: transparent;
}

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

מעקב

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

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

.gui-switch > input {
  appearance: none;
  border: none;
  outline-offset: 5px;
  box-sizing: content-box;

  padding: var(--track-padding);
  background: var(--track-color-inactive);
  inline-size: var(--track-size);
  block-size: var(--thumb-size);
  border-radius: var(--track-size);
}

מגוון רחב של אפשרויות התאמה אישית של המסלול המעבר מגיע מארבעה מאפיינים מותאמים אישית. הקוד border: none מתווסף כי הקוד appearance: none לא מסיר את הגבולות מהתיבה בכל הדפדפנים.

תמונות ממוזערות

אלמנט הסמן כבר נמצא בצד שמאל track אבל צריך להוסיף לו סגנונות עיגול:

.gui-switch > input::before {
  background: var(--thumb-color);
  border-radius: 50%;
}

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

אינטראקציה

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

.gui-switch > input::before {
  box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);

  @media (--motionOK) { & {
    transition:
      transform var(--thumb-transition-duration) ease,
      box-shadow .25s ease;
  }}
}

מיקום האגודל

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

רכיב input הוא הבעלים של משתנה המיקום --thumb-position, והסימול של האצבע משתמש בו כמיקום translateX:

.gui-switch > input {
  --thumb-position: 0%;
}

.gui-switch > input::before {
  transform: translateX(var(--thumb-position));
}

עכשיו אנחנו יכולים לשנות את --thumb-position מ-CSS ואת פסאודו-הכיתבים שסופקו ברכיבי התיבות הסימון. מכיוון שהגדרתנו את transition: transform var(--thumb-transition-duration) ease באופן מותנה קודם לכן ברכיב הזה, יכול להיות שהשינויים האלה יתבצעו עם אנימציה כשהם ישתנו:

/* positioned at the end of the track: track length - 100% (thumb width) */
.gui-switch > input:checked {
  --thumb-position: calc(var(--track-size) - 100%);
}

/* positioned in the center of the track: half the track - half the thumb */
.gui-switch > input:indeterminate {
  --thumb-position: calc(
    (var(--track-size) / 2) - (var(--thumb-size) / 2)
  );
}

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

לאורך

התמיכה בוצעה באמצעות סיווג מודификатор -vertical שמוסיף רוטציה עם טרנספורמציות CSS לרכיב input.

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

.gui-switch.-vertical {
  min-block-size: calc(var(--track-size) + calc(var(--track-padding) * 2));

  & > input {
    transform: rotate(-90deg);
  }
}

(RTL) מימין לשמאל

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

.gui-switch {
  --isLTR: 1;

  &:dir(rtl) {
    --isLTR: -1;
  }
}

מאפיין מותאם אישית שנקרא --isLTR בהתחלה מכיל את הערך 1, כלומר true כי הפריסה שלנו היא משמאל לימין כברירת מחדל. לאחר מכן, באמצעות פסאודו-הקלאס של CSS‏ :dir(), הערך מוגדר כ--1 כשהרכיב נמצא בפריסה מימין לשמאל.

כדי להשתמש ב---isLTR, צריך להשתמש בו בתוך calc() בתוך טרנספורמציה:

.gui-switch.-vertical > input {
  transform: rotate(-90deg);
  transform: rotate(calc(90deg * var(--isLTR) * -1));
}

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

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

.gui-switch > input:checked {
  --thumb-position: calc(var(--track-size) - 100%);
  --thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}

.gui-switch > input:indeterminate {
  --thumb-position: calc(
    (var(--track-size) / 2) - (var(--thumb-size) / 2)
  );
  --thumb-position: calc(
   ((var(--track-size) / 2) - (var(--thumb-size) / 2))
    * var(--isLTR)
  );
}

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

מדינות

השימוש ב-input[type="checkbox"] המובנה לא יושלם בלי לטפל במדינות השונות שבהן הוא יכול להיות: :checked, :disabled, :indeterminate ו-:hover. השארנו את :focus בכוונה ללא שינוי, והתאמנו רק את ההיסט שלו. טבעת המיקוד נראתה נהדר ב-Firefox וב-Safari:

צילום מסך של טבעת המיקוד שמתמקדת במתג ב-Firefox וב-Safari.

מסומן

<label for="switch-checked" class="gui-switch">
  Default
  <input type="checkbox" role="switch" id="switch-checked" checked="true">
</label>

המצב הזה מייצג את המצב on. במצב הזה, הרקע של הקלט לקלט מוגדר לצבע הפעיל, ומיקום האגודל מוגדר ל'סוף'.

.gui-switch > input:checked {
  background: var(--track-color-active);
  --thumb-position: calc((var(--track-size) - 100%) * var(--isLTR));
}

מושבת

<label for="switch-disabled" class="gui-switch">
  Default
  <input type="checkbox" role="switch" id="switch-disabled" disabled="true">
</label>

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

.gui-switch > input:disabled {
  cursor: not-allowed;
  --thumb-color: transparent;

  &::before {
    cursor: not-allowed;
    box-shadow: inset 0 0 0 2px hsl(0 0% 100% / 50%);

    @media (prefers-color-scheme: dark) { & {
      box-shadow: inset 0 0 0 2px hsl(0 0% 0% / 50%);
    }}
  }
}

המתג בסגנון כהה במצבים מושבת, מסומן ולא מסומן.

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

לא קבוע

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

קשה להגדיר תיבת סימון כבלתי מוגדרת. רק JavaScript יכול להגדיר אותה:

<label for="switch-indeterminate" class="gui-switch">
  Indeterminate
  <input type="checkbox" role="switch" id="switch-indeterminate">
  <script>document.getElementById('switch-indeterminate').indeterminate = true</script>
</label>

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

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

.gui-switch > input:indeterminate {
  --thumb-position: calc(
    calc(calc(var(--track-size) / 2) - calc(var(--thumb-size) / 2))
    * var(--isLTR)
  );
}

העברת העכבר מלמעלה

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

אפקט 'הדגשה' הסתיים עם box-shadow. כשמעבירים את העכבר מעל קלט לא מושבת, הגודל של --highlight-size גדל. אם המשתמש מסכים לתנועה, אנחנו מעבירים את box-shadow ורואים שהוא גדל. אם הוא לא מסכים לתנועה, ההדגשה מופיעה באופן מיידי:

.gui-switch > input::before {
  box-shadow: 0 0 0 var(--highlight-size) var(--thumb-color-highlight);

  @media (--motionOK) { & {
    transition:
      transform var(--thumb-transition-duration) ease,
      box-shadow .25s ease;
  }}
}

.gui-switch > input:not(:disabled):hover::before {
  --highlight-size: .5rem;
}

JavaScript

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

סימני לייק שניתן לגרור

הרכיב המדומה מקבל את המיקום שלו מההיקף של .gui-switch > input ב-var(--thumb-position), ו-JavaScript יכול לספק ערך סגנון מוטבע בקלט כדי לעדכן באופן דינמי את המיקום של האגודל כך שייראה כאילו הוא עוקב אחר התנועה של הסמן. כשמשחררים את הסמן, מסירים את הסגנונות המוטמעים ומחליטים אם הגרירה הייתה קרובה יותר להשבתה או להפעלה באמצעות המאפיין המותאם אישית --thumb-position. זה עמוד התווך של הפתרון: אירועי מצביע עוקבים באופן מותנה אחרי מיקומי המצביע כדי לשנות מאפיינים מותאמים אישית של CSS.

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

touch-action

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

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

.gui-switch > input {
  touch-action: pan-y;
}

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

כלי עזר לסגנון של ערכי פיקסלים

בתהליך ההגדרה ובמהלך הגרירה, יהיה צורך לשלוף מהאלמנטים ערכים שונים של מספרים מחושבים. פונקציות ה-JavaScript הבאות מחזירות ערכים מחושבים של פיקסלים על סמך מאפיין CSS. הוא משמש בסקריפט ההגדרה באופן הבא: getStyle(checkbox, 'padding-left').

​​const getStyle = (element, prop) => {
  return parseInt(window.getComputedStyle(element).getPropertyValue(prop));
}

const getPseudoStyle = (element, prop) => {
  return parseInt(window.getComputedStyle(element, ':before').getPropertyValue(prop));
}

export {
  getStyle,
  getPseudoStyle,
}

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

dragging

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

const dragging = event => {
  if (!state.activethumb) return

  let {thumbsize, bounds, padding} = switches.get(state.activethumb.parentElement)
  let directionality = getStyle(state.activethumb, '--isLTR')

  let track = (directionality === -1)
    ? (state.activethumb.clientWidth * -1) + thumbsize + padding
    : 0

  let pos = Math.round(event.offsetX - thumbsize / 2)

  if (pos < bounds.lower) pos = 0
  if (pos > bounds.upper) pos = bounds.upper

  state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)
}

הגיבור של התסריט הוא state.activethumb, העיגול הקטן שהסקריפט הזה ממקם יחד עם הסמן. האובייקט switches הוא Map() שבו המפתחות הם של .gui-switch והערכים הם גבולות וגדלים במטמון ששומרים על יעילות הסקריפט. המערכת מטפלת בכיוון מימין לשמאל באמצעות אותה מאפיין מותאם אישית של CSS, --isLTR, והיא יכולה להשתמש בו כדי להפוך את הלוגיקה ולהמשיך לתמוך ב-RTL. גם הערך event.offsetX חשוב, כי הוא מכיל ערך דלתא שמועיל למיקום האגודל.

state.activethumb.style.setProperty('--thumb-position', `${track + pos}px`)

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

dragEnd

כדי לאפשר למשתמש לגרור את המתג הרחק מחוץ לחלון ולשחרר אותו, צריך לרשום אירוע חלון גלובלי:

window.addEventListener('pointerup', event => {
  if (!state.activethumb) return

  dragEnd(event)
})

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

const dragEnd = event => {
  if (!state.activethumb) return

  state.activethumb.checked = determineChecked()

  if (state.activethumb.indeterminate)
    state.activethumb.indeterminate = false

  state.activethumb.style.removeProperty('--thumb-transition-duration')
  state.activethumb.style.removeProperty('--thumb-position')
  state.activethumb.removeEventListener('pointermove', dragging)
  state.activethumb = null

  padRelease()
}

האינטראקציה עם הרכיב הסתיימה, ועכשיו צריך להגדיר את מאפיין הבדיקה של הקלט ולהסיר את כל אירועי התנועות. תיבת הסימון משתנה ל-state.activethumb.checked = determineChecked().

determineChecked()

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

const determineChecked = () => {
  let {bounds} = switches.get(state.activethumb.parentElement)

  let curpos =
    Math.abs(
      parseInt(
        state.activethumb.style.getPropertyValue('--thumb-position')))

  if (!curpos) {
    curpos = state.activethumb.checked
      ? bounds.lower
      : bounds.upper
  }

  return curpos >= bounds.middle
}

מחשבות נוספות

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

const padRelease = () => {
  state.recentlyDragged = true

  setTimeout(_ => {
    state.recentlyDragged = false
  }, 300)
}

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

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

קוד JavaScript מהסוג הזה הוא קוד שאני הכי לא אוהב לכתוב, כי אני לא רוצה לנהל את ההעברה (bubbling) של אירועים מותנים:

const preventBubbles = event => {
  if (state.recentlyDragged)
    event.preventDefault() && event.stopPropagation()
}

סיכום

רכיב המתג הקטן הזה היה בסופו של דבר החלק הכי קשה בכל האתגרים של ממשקי המשתמש עד עכשיו! עכשיו, אחרי שסיפרתי לך איך עשיתי את זה, איך היית עושה את זה? 🙂

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

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

משאבים

קוד המקור של .gui-switch ב-GitHub