הפעלת אפקטים אחרי השלמת הגלילה הפרוגרמטית

תכונות באינטרנט: הבטחות לשיטת גלילה scrollIntoView()

כשגוללים באופן פרוגרמטי לקטע או לרכיב (במיוחד באמצעות behavior: 'smooth'), הפעלה של פעולת המשך באופן מיידי – כמו הבהוב של מצב הדגשה, הפעלה של אנימציית כניסה או העברת המיקוד – גורמת להפעלת האפקט כשהיעד עדיין מחוץ למסך או באמצע הגלילה.

בעבר, המפתחים היו צריכים לנחש את משך הגלילה באמצעות setTimeout() (שמופעל מוקדם מדי בגלילות ארוכות ומאוחר מדי בגלילות קצרות) או לעטוף באופן ידני רכיב event listener מסוג scrollend ב-Promise עם טיימר חלופי (כי scrollend לא מופעל כשהיעד כבר מוצג ולא מתבצעת גלילה).

השיטות הפרוגרמטיות לגלילה ב-Element (scrollIntoView(), scroll(), scrollTo(), scrollBy()) וב-Window (scroll(), scrollTo(), scrollBy()) מחזירות Promise שמתבצעת כשהגלילה מסתיימת. אם היעד כבר נמצא במיקום הגלילה המבוקש ואין צורך בגלילה, ה-Promise שמוחזר נפתר מיד בלי להיתקע.

הטמעה

  1. מסמנים את ה-handler כ-async: משתמשים בפונקציה async עבור רכיב ה-event listener או ה-navigation handler שמפעילים את הגלילה.
  2. המתנה ישירה ל-scroll method: קוראים ל-await element.scrollIntoView({ behavior: 'smooth', ... }) (או ל-await element.scrollTo(...), ל-await window.scrollTo(...) וכו'). ההפעלה מושהית עד שהדפדפן מסיים את אנימציית הגלילה ומיקום הגלילה מתייצב.
  3. החלת האפקט אחרי הגלילה: מחליפים את מחלקת ההדגשה, מתחילים את האנימציה או מעבירים את המיקוד אחרי שהביטוי await מסתיים.

גלילות שנקטעו וניהול המיקוד

  • טיפול בגלילות מהירות או מופסקות: אם גלילה חדשה מתחילה באותו מאגר גלילה לפני שגלילה חלקה מתמשכת מסתיימת, הדפדפן מבטל את הגלילה החלקה המתמשכת ופותר (לא דוחה) את כל ההבטחות לגלילה בהמתנה במאגר הזה. אם אתם צריכים לבטל את האפקט שאחרי הגלילה בגלילות שהוחלפו כשהמשתמש לוחץ במהירות, עקבו אחרי מזהה פעולה הולך וגדל וחזרו מוקדם אחרי await אם התחילה גלילה חדשה יותר:
let currentScrollId = 0;
const sections = document.querySelectorAll('section');

async function scrollToAndHighlight(targetElement) {
  const scrollId = ++currentScrollId;
  sections.forEach((sec) => sec.classList.remove('highlight'));

  await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });

  // Abort follow-up effect if another scroll was triggered while awaiting
  if (scrollId !== currentScrollId) return;

  targetElement.classList.add('highlight');
}
  • התמקדות ביעד אחרי גלילה: כשמעבירים את מיקוד המקלדת לרכיב היעד (או לכותרת בתוכו באמצעות tabindex="-1") אחרי await targetElement.scrollIntoView(...), מעבירים את { preventScroll: true } אל focus() כדי שהקריאה למיקוד לא תפעיל גלילה שנייה שמתחרה בראשונה:
await targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
targetHeading.focus({ preventScroll: true });

מגבלות ושיטות מומלצות

  • חובה: צריך להמתין ל-Promise שמוחזר ישירות על ידי scrollIntoView(),‏ scroll(),‏ scrollTo() או scrollBy(), במקום לנחש את משכי הזמן של הגלילה באמצעות setTimeout().
  • אל תעטפו את scrollend ב-new Promise() ידני כהטמעה הראשית שלכם – scrollend לא מופעל כשהיעד כבר מוצג ולא מתבצעת גלילה, ואילו הבטחות של שיטת הגלילה נפתרות באופן אוטומטי במקרה כזה.
  • מה כן לעשות: מעבירים את { preventScroll: true } כשקוראים ל-.focus() אחרי שממתינים ל-method של גלילה, כדי שהדפדפן לא יקפוץ או יגלול פעם נוספת.
  • מומלץ: להגן על אפקטים שמופעלים אחרי גלילה באמצעות אסימון בקשה, אם אינטראקציות מהירות של משתמשים יכולות להפעיל גלילות חלקות חופפות באותו מאגר, כי הפרעה לגלילה חלקה פותרת (ולא דוחה) את ההבטחה הקודמת.
  • באג ידוע ב-Chrome עם גלילת הבסיס: ב-Chrome יש באג שבו ה-Promise שמוחזר על ידי scrollIntoView() נפתר מוקדם מדי כשגלילת הבסיס של המסמך היא זו שגוללת. המתנה ל-scrollIntoView() במאגר גלילה בתצוגת עץ פועלת כמצופה. כדי לעקוף את הבאג הזה, אל תעטפו את התוכן במאגר גלילה, כמו div עם overflow-y: auto.

אסטרטגיות חלופיות

בדפדפנים שלא תומכים בהבטחות של שיטת הגלילה, scrollIntoView(),‏ scroll(),‏ scrollTo() ו-scrollBy() מחזירים undefined במקום Promise.

  • שיפור הדרגתי (התנהגות ברירת המחדל של await): מכיוון ש-await undefined הוא JavaScript תקין שנפתר מיד במיקרו-משימה הבאה, כתיבת await element.scrollIntoView({ behavior: 'smooth' }) לא תגרום לשגיאה בדפדפנים לא נתמכים. במקום זאת, הוא פועל בצורה חלקה על ידי הפעלת הקוד שלאחר הגלילה ברגע שהגלילה מתחילה. בדרך כלל זה מספיק לאלמנטים חזותיים לא קריטיים, כמו הדגשה זמנית.
  • חלופה אמינה (scrollend + זמן קצוב לתפוגה): אם יעד Baseline לא תומך ב-scroll-promises והפעולה שאחרי הגלילה צריכה להמתין עד שהגלילה תסתיים בדפדפנים ישנים יותר, צריך לבדוק אם ערך ההחזרה הוא Promise (result instanceof Promise). כשהוא undefined, צריך להמתין לאירוע scrollend במאגר הגלילה עם זמן קצוב לתפוגה קצר למקרה שהרכיב כבר גלוי ולא מתרחשת גלילה:
function scrollIntoViewAsync(element, options, scroller = window) {
  const result = element.scrollIntoView(options);

  // Feature detect: in supporting browsers, scrollIntoView() returns a Promise
  if (result instanceof Promise) {
    return result;
  }

  // Fallback for browsers where scrollIntoView() returns undefined
  return new Promise((resolve) => {
    let timeoutId;

    const finish = () => {
      clearTimeout(timeoutId);
      scroller.removeEventListener('scrollend', finish);
      scroller.removeEventListener('scroll', onScroll);
      resolve();
    };

    const onScroll = () => {
      // Restart the idle timer on every scroll event. If scrollend never fires
      // (browsers without scrollend support), this resolves once scrolling goes idle.
      clearTimeout(timeoutId);
      timeoutId = setTimeout(finish, 100);
    };

    scroller.addEventListener('scrollend', finish, { once: true });
    scroller.addEventListener('scroll', onScroll, { passive: true });

    // If no scroll event fires within the idle window (e.g., already in view), resolve.
    // 100ms is an example value; it only needs to exceed the gap between scroll events.
    timeoutId = setTimeout(finish, 100);
  });
}