משוב בזמן אמת על גלילה עם נקודות עצירה (scroll snapping)

סקירה כללית

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

הטמעה

1. האזנה לscrollsnapchanging

מצרפים רכיב event listener של scrollsnapchanging למאגר הגלילה. האירוע הזה מופעל כשהדפדפן קובע שסביר להניח שייבחר יעד הצמדה חדש.

const container = document.querySelector('#gallery');
const thumbnails = document.querySelectorAll('.thumbnail');
const items = document.querySelectorAll('.gallery-item');

container.addEventListener('scrollsnapchanging', (event) => {
  // Highlight pending snap target during scroll for real-time feedback.
  const pendingTarget = event.snapTargetInline;
  const index = [...items].indexOf(pendingTarget);

  if (index === -1 || !thumbnails[index]) return;

  // Use lightweight class toggle to avoid layout thrashing during rapid events.
  // Note: aria-current is NOT toggled here. It tracks the settled "current"
  // item, which is updated in the scrollsnapchange handler below.
  thumbnails.forEach((thumb) => thumb.classList.remove('pending'));
  thumbnails[index].classList.add('pending');
});

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

2. האזנה לscrollsnapchange

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

container.addEventListener('scrollsnapchange', (event) => {
  // Promote pending state to active on scroll completion.
  const snappedTarget = event.snapTargetInline;
  const index = [...items].indexOf(snappedTarget);

  if (index === -1 || !thumbnails[index]) return;

  // Establish final active state and clean up pending.
  thumbnails.forEach((thumb) => {
    thumb.classList.remove('pending', 'active');
    thumb.removeAttribute('aria-current');
  });
  thumbnails[index].classList.add('active');
  thumbnails[index].setAttribute('aria-current', 'true');
});

3. סנכרון המצב ההתחלתי

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

// Note: For item.offsetLeft to be relative to the container, 
// the container MUST be the offsetParent (e.g., `position: relative`).
const findClosestItemIndex = () => {
  // Center-distance assumes scroll-snap-align: center on items.
  // For start-aligned snap, compare scrollLeft to item.offsetLeft directly.
  const containerCenter = container.scrollLeft + container.clientWidth / 2;
  let closestIndex = 0;
  let minDistance = Infinity;

  items.forEach((item, index) => {
    const itemCenter = item.offsetLeft + item.offsetWidth / 2;
    const distance = Math.abs(containerCenter - itemCenter);
    if (distance < minDistance) {
      minDistance = distance;
      closestIndex = index;
    }
  });
  return closestIndex;
};

const initActiveItem = () => {
  const closestIndex = findClosestItemIndex();
  if (!thumbnails[closestIndex]) return;

  thumbnails.forEach((thumb) => {
    thumb.classList.remove('pending', 'active');
    thumb.removeAttribute('aria-current');
  });
  thumbnails[closestIndex].classList.add('active');
  thumbnails[closestIndex].setAttribute('aria-current', 'true');
};

if (document.readyState === 'complete') {
  initActiveItem();
} else {
  window.addEventListener('load', initActiveItem, { once: true });
}

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

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

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

מומלץ לדמות את האופי של scrollsnapchanging בזמן אמת באמצעות רכיב event listener מסוג scroll בשילוב עם requestAnimationFrame וחישובי מרחק גיאומטריים כדי לקבוע את יעד ההצמדה הקרוב ביותר בזמן שהמשתמש גולל באופן פעיל.

if ('onscrollsnapchanging' in Element.prototype) {
  // Use native scroll snap events
} else {
  // Fallback: use scroll + requestAnimationFrame for eager feedback
  // (assumes the same container, thumbnails, items defined in step 1)
  let scrollTimeout;
  let rafId = null;

  const promotePendingToActive = () => {
    const closestIndex = findClosestItemIndex();
    if (!thumbnails[closestIndex]) return;
    thumbnails.forEach((thumb) => {
      thumb.classList.remove('pending', 'active');
      thumb.removeAttribute('aria-current');
    });
    thumbnails[closestIndex].classList.add('active');
    thumbnails[closestIndex].setAttribute('aria-current', 'true');
  };

  container.addEventListener('scroll', () => {
    if (rafId) return;
    rafId = requestAnimationFrame(() => {
      rafId = null;
      const closestIndex = findClosestItemIndex();
      if (!thumbnails[closestIndex]) return;

      // DO NOT forget to clean up stale pending classes
      thumbnails.forEach((thumb) => thumb.classList.remove('pending'));
      thumbnails[closestIndex].classList.add('pending');
    });

    // Debounce fallback for browsers that don't support scrollend
    clearTimeout(scrollTimeout);
    scrollTimeout = setTimeout(promotePendingToActive, 100);
  }, { passive: true });

  // Fallback: use Baseline `scrollend` event to promote pending to active cleanly where supported
  container.addEventListener('scrollend', () => {
    clearTimeout(scrollTimeout);
    promotePendingToActive();
  });
}

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