הזזה דו-ממדית

תכונות אינטרנט: scroll-axis-lock overscroll-behavior

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

נעילת הציר מונעת תזוזה מקרית הצידה בתהליכי קריאה חד-ממדיים, אבל היא יוצרת חיכוך במשטחי הזזה דו-ממדיים כמו מפות אינטראקטיביות, תמונות מוגדלות, לוחות אינסופיים, תרשימי צמתים ורשתות נתונים גדולות. מכיוון שהנעילה נמשכת לאורך התנועה, היא משפיעה על כל הזזה שכוללת שני כיוונים – בין אם שני הצירים מוזזים בו-זמנית (הזזה אלכסונית) או אחד אחרי השני בתנועה אחת (כמו נתיבים מעוקלים, מעגליים או בצורת L). המאפיין scroll-axis-lock ב-CSS משבית את ההיוריסטיקה הזו ישירות בקונטיינר גלילה מקורי, ומאפשר הזזה דו-ממדית ללא הגבלה תוך שמירה על גלילה ב-compositor-thread, פיזיקה של מומנטום, פסי גלילה מקוריים וניווט באמצעות המקלדת.

הטמעה

1. השבתת נעילת הציר והגבלת הגלילה מעבר לגבולות במאגר הגלילה הדו-ממדי

צריך להחיל את scroll-axis-lock: none ישירות על הרכיב שיוצר את מאגר הגלילה הדו-ממדי (overflow: auto או overflow: scroll). מאגר גלילה דו-ממדי יכול כבר לעדכן את השינויים בגלילה בשני הצירים בערך ברירת המחדל auto, אבל auto מאפשר להפעיל את ההיוריסטיקה של הדפדפן כשמתחילים תנועה בעיקר לאורך ציר אחד. ההגדרה scroll-axis-lock: none מונעת את הפעלת המעקה הזה במסכי מגע, במשטחי מגע ובגלגלי עכבר.

צריך להתאים את scroll-axis-lock: none ל-overscroll-behavior: none (או ל-overscroll-behavior: contain) במאגר הגלילה, כדי שהגעה לקצה לא תגרום לגלילה בדף ההורה או תפעיל בטעות ניווט בגלילה מעבר לקצה בדפדפן (כמו ניווט בהחלקה אופקית אחורה/קדימה או משיכה למטה לרענון).

2. שמירה על יכולת המיקוד והנגישות של המקלדת

במשטח הזזה דו-ממדי בדרך כלל אין פקדי צאצא שאפשר להתמקד בהם (לדוגמה, מפה או תמונה מוגדלת). לכן, כדי שמשתמשים שנעזרים במקלדת יוכלו להתמקד בקונטיינר ולהזיז אותו באמצעות מקשי החיצים, צריך להוסיף tabindex="0", תווית נגישה וrole מתאימה, ואינדיקטור :focus-visible גלוי.

קוד לדוגמה

<!-- Make the 2D scroll container keyboard-focusable and labeled so keyboard
     users can focus the viewport and pan in all directions with Arrow keys. -->
<div class="pan-viewport" tabindex="0" role="region" aria-label="Interactive canvas">
  <div class="pan-surface">
    <!-- 2D overflowing content (map tiles, zoomed image, canvas nodes, or grid) -->
  </div>
</div>
.pan-viewport {
  /* Example viewport dimensions; adjust to fit your layout. */
  inline-size: 100%;
  block-size: 500px;

  /* MANDATORY: Establish a scroll container that can overflow on both axes. */
  overflow: auto;

  /* MANDATORY: Prevent browser scroll axis-locking ("railing") from kicking in
     so 2D gestures never get locked to a single axis. */
  scroll-axis-lock: none;

  /* Contain scrolling and prevent accidental swipe navigations at edges
     (use `none` or `contain`). */
  overscroll-behavior: none;
}

/* Provide a clear focus indicator when keyboard users focus the viewport. */
.pan-viewport:focus-visible {
  outline: 3px solid #005fcc;
  outline-offset: 2px;
}

.pan-surface {
  /* Example surface dimensions larger than .pan-viewport in both axes. */
  inline-size: 3000px;
  block-size: 3000px;
}

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

  • צריך להחיל את המאפיין על מאגר הגלילה עצמו, ולא על מאגר אב: המאפיין scroll-axis-lock לא עובר בירושה, ולכן הגדרה שלו במאגר אב לא משפיעה על מאגרי גלילה צאצאים.
  • הגבלת גלילה מעבר לקצה ומניעת ניווט מקרי: מוסיפים את overscroll-behavior: none או את overscroll-behavior: contain למאגר הגלילה הדו-ממדי, כדי שהזזה לתוך גבול לא תקושר לדף ההורה או תפעיל ניווט בהחלקה קדימה/אחורה בדפדפן ומחוות של משיכה למטה לרענון.
  • חלוניות חד-ממדיות בתצוגת עץ ממשיכות להשתמש במעקה באופן אוטומטי: מכיוון ש-scroll-axis-lock לא עובר בירושה, סרגל צד או חלונית בדיקה בתצוגת עץ בתוך .pan-viewport שומרים על התנהגות ברירת המחדל של scroll-axis-lock: auto.
  • השאירו את auto בפידים אנכיים רגילים, במאמרים וברשימות חד-ממדיות: נעילת הציר מונעת תזוזה לא מכוונת בכיוון הניצב בתוכן חד-כיווני.
  • אל תגדירו את touch-action: none ללא תנאי: touch-action קובע אם הדפדפן מטפל בתנועות מגע בכלל (ואין לו השפעה על קלט של משטח מגע או גלגלת), כך שהגדרה שלו משביתה את הגלילה המקורית במגע של ה-compositor. צריך להחיל אותו רק בתוך @supports not (scroll-axis-lock: none) כשמטמיעים את השיטה החלופית של מצביע JavaScript.
  • הזזה באמצעות scrollLeft או scrollTop, אף פעם לא באמצעות transform ב-CSS: הזזה של המשטח באמצעות transform מונעת מהרכיב לפעול כמאגר גלילה – מה שמשבש את פסי הגלילה המקוריים, את הגבלת הגבולות, את הגלילה באמצעות מקשי החיצים במקלדת, את האירועים של scroll, את IntersectionObserver ואת האנימציות שמופעלות בגלילה.

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

בוחרים בין שיפור הדרגתי (progressive enhancement) לבין חלופה של JavaScript, בהתאם לדרישות של האפליקציה:

אפשרות 1: שיפור הדרגתי (ללא JavaScript)

כדאי להשתמש בשיפור הדרגתי (progressive enhancement) כשחשוב יותר לשמר את הגלילה במגע, את התנופה האינרציאלית ואת התנועה של צביטה כדי לשנות את מרחק התצוגה בשרשור המקורי של ה-compositor, מאשר לבטל את נעילת הציר בדפדפנים לא נתמכים:

  • דפדפנים תומכים: הדפדפן מונע מההיוריסטיקה של נעילת הציר ("מעקה") לפעול במאגר הגלילה הדו-ממדי במהלך תנועות גלילה.
  • דפדפנים לא נתמכים: דפדפנים שלא מזהים את scroll-axis-lock: none מתעלמים בבטחה מההצהרה ושומרים על התנהגות ברירת המחדל של גלילה דו-ממדית ב-auto – גלילה בשני הצירים באופן מקורי, תוך שמירה על תנועות שמתחילות בעיקר לאורך ציר אחד.

אפשרות 2: חלופת JavaScript‏ (wheel + pointermove עם scrollLeft/scrollTop)

אם נדרשת הזזה דו-ממדית ללא הגבלה בכל הדפדפנים, צריך להשתמש ב-@supports not (scroll-axis-lock: none) וב-CSS.supports('scroll-axis-lock', 'none') כדי ליירט באופן מותנה את wheel ואת תנועות הגרירה של הסמן.

‫wheel (e.deltaX, e.deltaY) ו-pointermove (e.clientX, e.clientY) חושפים את השינויים הגולמיים הלא נעולים ב-2D לפני שמנוע הגלילה של הדפדפן מחיל נעילת ציר, לכן אפשר להחיל את השינויים האלה ישירות על scrollLeft ו-scrollTop:

  • למה scrollLeft/scrollTop במקום CSS transform: עדכון scrollLeft ו-scrollTop שומר על הרכיב כמאגר גלילה אמיתי. הדפדפן מגביל באופן אוטומטי את מיקומי הגלילה ל-[0, scrollWidth - clientWidth] ול-[0, scrollHeight - clientHeight], פסי הגלילה המקוריים נשארים מסונכרנים, הגלילה באמצעות מקשי החיצים במקלדת ממשיכה לפעול, ואירועי scroll ו-IntersectionObserver מופעלים כרגיל.
  • פשרות: יירוט של wheel באמצעות { passive: false } מעביר את הגלילה לשרשור הראשי, והגדרה של touch-action: none במסכי מגע משביתה את התנופה האינרציאלית המקורית אחרי pointerup (וגם את התנועה המקורית של צביטה לצורך שינוי מרחק התצוגה).
/* Only disable native touch scrolling when scroll-axis-lock is unsupported
   and the JavaScript pointer fallback below is active. */
@supports not (scroll-axis-lock: none) {
  .pan-viewport {
    touch-action: none;
  }
}
// Only attach the main-thread gesture fallback when native support is missing
if (!CSS.supports('scroll-axis-lock', 'none')) {
  const viewport = document.querySelector('.pan-viewport');
  let lastPointer = null;

  // 1. Trackpad and mouse wheel fallback
  viewport.addEventListener(
    'wheel',
    (e) => {
      e.preventDefault();
      viewport.scrollLeft += e.deltaX;
      viewport.scrollTop += e.deltaY;
    },
    { passive: false },
  );

  // 2. Touch and pointer drag fallback
  viewport.addEventListener('pointerdown', (e) => {
    // Ignore non-primary buttons and clicks on native scrollbars
    const rect = viewport.getBoundingClientRect();
    if (
      !e.isPrimary ||
      e.button !== 0 ||
      e.clientX - rect.left - viewport.clientLeft >= viewport.clientWidth ||
      e.clientY - rect.top - viewport.clientTop >= viewport.clientHeight
    ) {
      return;
    }
    lastPointer = { x: e.clientX, y: e.clientY };
    viewport.setPointerCapture(e.pointerId);
  });

  viewport.addEventListener('pointermove', (e) => {
    if (!lastPointer) return;
    viewport.scrollLeft -= e.clientX - lastPointer.x;
    viewport.scrollTop -= e.clientY - lastPointer.y;
    lastPointer = { x: e.clientX, y: e.clientY };
  });

  const endPointerPan = () => {
    lastPointer = null;
  };
  viewport.addEventListener('pointerup', endPointerPan);
  viewport.addEventListener('pointercancel', endPointerPan);
}