تمرير سريع للإزالة

تُعد أنماط التمرير السريع للإزالة شائعة في تطبيقات الأجهزة الجوّالة، ولكن قد يكون من الصعب تنفيذها بشكل سلس على الويب. باستخدام CSS Scroll Snap، يمكنك إنشاء تفاعل تمرير سلس يشبه التفاعل الأصلي ويرتبط مباشرةً بمحرّك التمرير في المتصفّح. يضمن ذلك الأداء العالي والزخم المستند إلى الفيزياء بدون الحاجة إلى مكتبة إيماءات JavaScript معقّدة.

ينطبق النمط نفسه على أي إجراء تمرير سريع واحد (إزالة، أرشفة، وضع علامة "مقروءة"، تأجيل). تتغير المؤثرات البصرية للحركة، لكن لا تتغير آليات اللعب.

إذا أردت بدلاً من ذلك التمرير سريعًا بشكل أفقي لفتح مجموعة من أزرار الإجراءات أو قائمة جانبية بدون تنفيذ إجراء تلقائيًا أثناء التمرير السريع، يُرجى الاطّلاع على التمرير السريع للكشف.

كيفية الإعداد

يتكوّن المكوّن من طبقتَين: قائمة (<ul>) وعناصر بداخلها. يتم تنظيم كل عنصر على شكل <li> خارجي، ومسار تمرير داخلي (حاوية التمرير مع نقاط المحاذاة)، وعنصر محتوى (الصف المرئي). تظهر واجهة المستخدم الخاصة بالإجراء (رمز المهملات وتصنيف الأرشيف وما إلى ذلك) على أحد جانبَي المحتوى. تتضمّن القائمة أسلاكًا مشتركة (إعداد العناصر الكسولة، واختيار العناصر المُضافة حديثًا)، ولكل عنصر ميزة رصد التمرير السريع الخاصة به.

الخطوة 1: ترميز القائمة باستخدام المسار والمحتوى

<ul class="SwipeableList">
  <li id="list-item-1" class="SwipeableList-item">
    <div class="SwipeableList-track">
      <div class="SwipeableList-content">Item One</div>
    </div>
  </li>
  <li id="list-item-2" class="SwipeableList-item">
    <div class="SwipeableList-track">
      <div class="SwipeableList-content">Item Two</div>
    </div>
  </li>
  <!-- ...more items... -->
</ul>

الخطوة 2: ضبط المسار كحاوية محاذاة أفقية بثلاث نقاط محاذاة

يتضمّن المسار ثلاثة أعمدة بعرض كامل: فاصل أيسر (::before) والمحتوى وفاصل أيمن (::after). يعني الانطباق على فاصل أنّ المحتوى خارج الشاشة بالكامل، وهو موضع السكون بعد التمرير السريع.

يتم حظر إعدادات المسار خلف فئة .is-initialized في عنصر القائمة. قبل أن ترقّي JS الصف، يتم عرض العنصر كمحتوى عادي بدون شريط تمرير أفقي. تتم إضافة الفئة في الخطوة 4 بعد ربط JavaScript الذي يرصد التمريرات السريعة. يضمن ذلك عدم تمكّن المستخدم من التمرير السريع قبل أن تصبح الوظيفة جاهزة.

.SwipeableList {
  list-style: none;
}

.SwipeableList-item {
  /* Establishes a containing block for the absolutely positioned action
     icons (Step 3) and clips overflow during the row's removal
     animation (Step 4). */
  contain: content;
}

/* The track only becomes a scroll snap container after JS upgrades
   the row by adding `.is-initialized` (see Step 4). */
.SwipeableList-item.is-initialized .SwipeableList-track {
  /* Three full-width columns: left spacer | content | right spacer.
     Width is 100% of the track, so each column fills the viewport row. */
  display: grid;
  grid-template-columns: 100% 100% 100%;

  /* Horizontal scroll only; vertical overflow is clipped so the
     reveal stays inside the row. */
  overflow: scroll clip;

  /* Prevent the swipe from chaining into the page scroll or browser
     back-gesture on iOS/Android. */
  overscroll-behavior-x: none;

  /* Hide the scrollbar; the gesture is the affordance. */
  scrollbar-width: none;

  /* `mandatory` ensures the track always rests on a snap point
     (spacer or content), never partially scrolled. */
  scroll-snap-type: x mandatory;
}

/* Spacers act as the left and right snap targets, AND carry the action's
   reveal color. As the user swipes, the colored spacer slides into view,
   which is what the user sees behind the content. */
.SwipeableList-item.is-initialized .SwipeableList-track::before,
.SwipeableList-item.is-initialized .SwipeableList-track::after {
  content: '';

  /* `scroll-snap-align` is required to make this a valid snap target,
     but the specific value (`start`/`center`/`end`) doesn't matter here
     because each snap point spans the full width of the scroll
     container, so all alignments resolve to the same resting position. */
  scroll-snap-align: start;

  /* `hsl(0 65% 50%)` is an example value; pick whatever fits your
     design (red here signals "delete"; use a different color for
     archive, mark-as-read, etc.). */
  background-color: hsl(0 65% 50%);
}

.SwipeableList-content {
  /* The content sits above the action icons (Step 3), so it covers
     them until the user swipes. */
  position: relative;
  z-index: 2;

  /* Required to make the content a valid snap target (its resting
     position). As with the spacers above, the specific value doesn't
     matter because the snap points are full-width. */
  scroll-snap-align: start;

  /* The content must paint over the revealed spacer color. */
  background: Canvas;

  /* Row separator (example value; customize to taste). */
  border-bottom: 1px solid #eee;
}

/* Gate `scroll-initial-target` behind `.is-initialized` so it only
   applies once the track is actually a scroll container. Setting it on
   the content before then would let the property walk up to the nearest
   scrollable ancestor (typically the document) and shift the page's
   initial scroll position to bring this row's content into view. With
   the gate, the rule is only live when the row's own track can satisfy
   it, so the initial scroll happens inside the track as intended. */
.SwipeableList-item.is-initialized .SwipeableList-content {
  scroll-initial-target: nearest;
}

/* The track is the focusable scroll container, but its overflow is clipped
   so a default focus ring on the track itself would be invisible. Project
   the focus affordance onto the content element (which paints above the
   track) using `:focus-visible` on the track. */
.SwipeableList-track:focus-visible .SwipeableList-content {
  outline: auto;
  outline-offset: -2px;
}

الخطوة 3: إضافة رموز الإجراءات إلى عنصر القائمة

يظهر لون الإجراء على الفواصل (الخطوة 2). يظهر رمز الإجراء على عنصر القائمة نفسه، ويكون مثبّتًا على الحواف اليمنى واليسرى للصف.

إنّ الموضع والحجم والحركة أدناه هي اقتراحات للبدء، وليست متطلبات. يمكنك ضبط حجم الرمز، والمسافات البادئة للحواف، ومقياس الحدّ الأدنى للظهور، ومدة الانتقال، وحتى اختيار العناصر الزائفة مقابل عُقد DOM الحقيقية لتناسب تصميمك. الشرط الميكانيكي الوحيد هو أن يكون الرمز خلف المحتوى (بحيث يمكن للمحتوى تغطيته قبل التمرير السريع) وداخل عنصر القائمة (بحيث لا يتم تمريره مع الفاصل). أما الباقي، فيعتمد على الذوق.

/* Action icons painted on the list item. They're absolutely positioned
   inside the row (which is a containing block thanks to `contain: content`
   on `.SwipeableList-item` from Step 2) and sit at z-index 1, so the
   content element (z-index 2) covers them until the user swipes far
   enough. */
.SwipeableList-item.is-initialized::before,
.SwipeableList-item.is-initialized::after {
  /* Inline an SVG as the action icon. Replace this with whatever icon
     fits the action (archive, checkmark, clock, etc.). The `fill='white'`
     is baked into the SVG so it contrasts with the red spacer background;
     adjust if your background color is light. */
  --action-icon: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'><path d='M9 3v1H4v2h1v13a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V6h1V4h-5V3H9zm0 5h2v9H9V8zm4 0h2v9h-2V8z'/></svg>");

  content: '';
  position: absolute;
  z-index: 1;

  /* The size (`width`) and insets (`left`/`right` below) are example
     values, tune them to match your row height and visual weight.
     The icon fills its box via `background-size: contain`, so changing
     `width` resizes it. */
  width: 1.5em; /* example value, adjust to taste */
  aspect-ratio: 1;
  top: 50%;
  translate: 0 -50%;

  /* Smooth transitions for the icon's visual states: the activate-point
     pop (`scale`, see `.is-activating` below) and the removal fade
     (`scale` + `opacity`, see `.is-removing` below). 0.2s is an example
     duration. */
  transition: scale 0.2s ease, opacity 0.2s ease;

  background: var(--action-icon) center / contain no-repeat;
}
/* Inset from the row edge (example values; adjust to match your layout). */
.SwipeableList-item.is-initialized::before { left: 1.5em; }
.SwipeableList-item.is-initialized::after  { right: 1.5em; }

/* Activating pop: scale the icon up when the user is past the visual
   activate point, so the row's affordance feels reactive. Toggled by JS
   in Step 4. */
.SwipeableList-item.is-activating::before,
.SwipeableList-item.is-activating::after {
  scale: 1.333;
}

/* Removal affordance: fade and shrink the icons as the row collapses.
   Driven by the `is-removing` class added in Step 4 at commit time;
   the existing `transition` on the icons animates the change. */
.SwipeableList-item.is-removing::before,
.SwipeableList-item.is-removing::after {
  scale: 0.5;
  opacity: 0;
}

/* Only show the icon on the *leading* side of the swipe; hide the
   trailing-side one. `data-swipe-direction` is set by JS in Step 4. */
.SwipeableList-item.is-activating[data-swipe-direction="left"]::after,
.SwipeableList-item.is-activating[data-swipe-direction="right"]::before {
  visibility: hidden;
}

الخطوة 4: رصد إيماءة الإرسال باستخدام IntersectionObserver

استخدِم IntersectionObserver المتجذّر في المسار، مع مراعاة المحتوى. عندما يمرّر المستخدم المحتوى، تنخفض نسبة تقاطع المحتوى مع المسار. نستخدم حدَّين:

  • ‫activateThreshold: نسبة عالية (مثلاً 0.8) وعندما ينخفض الجزء المرئي من المحتوى عن هذا الحدّ، يكون المستخدم قد تجاوز نقطة التفعيل المرئية. انقر على رمز مفتاح التبديل.
  • ‫commitThreshold: نسبة منخفضة (مثلاً 0.2) وعندما ينخفض الجزء المرئي عن هذا الحدّ، يكون المستخدم قد التزم بالاشتراك. ابدأ حركة الإزالة على الفور، بدون انتظار استقرار إيماءة الإطباق بالكامل. يندمج الصف القابل للتصغير مع زخم التمرير المستمر للمستخدم، ما يمنحه شعورًا بالاستجابة السريعة بدلاً من الانتظار حتى يستقر الصف في مكانه قبل التفاعل.

في ما يلي مشكلتان إضافيتان يتم التعامل معهما:

  • الإعداد الكسول لكل عنصر: عنصر IntersectionObserver خارجي واحد متجذّر في إطار العرض يتحكّم في الإعداد وبدء/إيقاف مراقبي التمرير الداخليين. يتم ربط العناصر فقط في المرة الأولى التي يتم فيها التمرير إلى طريقة العرض، ويتم إيقاف مراقب التمرير مؤقتًا للعناصر التي يتم تمريرها خارج الشاشة. يؤدي ذلك إلى إبقاء عدد المراقبين النشطين محدودًا وتجنُّب قراءة القيم التي تعتمد على التنسيق (مثل clientWidth) قبل عرض العنصر.
  • العناصر الديناميكية: تزداد القوائم الحقيقية بمرور الوقت (العرض الأولي، والتمرير لا نهائي، وإرسال المعلومات إلى العميل قبل طلبها من الخادم). يُسجِّل MutationObserver في <ul> أي عناصر تمت إضافتها حديثًا مع المراقب الخارجي.
// Per-item handles. Populated when an item is first lazily wired up; read by
// the outer viewport observer to start/stop the inner observer as items enter
// and leave the viewport.
const swipeObservers = new WeakMap();

// Outer observer: drives the entire swipe lifecycle off viewport visibility.
// On first entry, lazily wires the item up (`setupItem` reads layout-dependent
// values like `clientWidth`, which return 0 until the item is rendered). After
// setup, starts the inner observer. On exit, stops the inner observer so
// offscreen items don't track scroll positions.
const viewportObserver = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    const item = entry.target;
    if (entry.isIntersecting) {
      const handle = swipeObservers.get(item) ?? setupItem(item);
      handle.observer.observe(handle.content);
    } else {
      const handle = swipeObservers.get(item);
      if (handle) handle.observer.unobserve(handle.content);
    }
  }
});

function setupItem(item) {
  const track = item.querySelector('.SwipeableList-track');
  const content = track.querySelector('.SwipeableList-content');

  // Upgrade the row into "swipeable" mode. This is the gate for all the CSS
  // from Steps 2 and 3 (the track becomes a snap container, the action icons
  // appear). Done *before* the inner observer is attached so the snap
  // container exists by the time intersection callbacks can fire.
  item.classList.add('is-initialized');

  // Tunable thresholds. `activateThreshold` is the visual feedback point
  // (icon pops). `commitThreshold` is the point of no return: once the
  // content is past this point of being off-screen, we commit even if the
  // user releases mid-gesture and the track snaps back. A low value (~0.2)
  // commits before the snap settles, so the remove animation can start
  // during the swipe.
  const activateThreshold = 0.8;
  const commitThreshold = 0.2;

  // One inner observer per item, rooted at the track. Vertical scrolling of
  // the outer list moves root and target together, so the callback only fires
  // for the horizontal swipe.
  const observer = new IntersectionObserver((entries, observer) => {
    const entry = entries.at(-1);
    const ratio = entry.intersectionRatio;

    // Direction the user is swiping toward. A positive offset from the
    // track's left edge means the content has been pulled right (left
    // spacer revealed), so the leading icon is on the left.
    const direction = (entry.boundingClientRect.x - entry.rootBounds.x) > 0
      ? 'left'
      : 'right';

    if (ratio < commitThreshold) {
      // The IO entry's boundingClientRect is the last reliable measurement
      // before the animation starts; reuse it for both the pre-collapse
      // height and the slide-off translate distance.
      removeItem(item, content, direction, entry);
      viewportObserver.unobserve(item);
      observer.disconnect();
      return;
    }

    // Scale up the leading icon while the content is past the activate
    // point; restore it at rest.
    item.classList.toggle('is-activating', ratio < activateThreshold);

    // Hold the previous direction at rest so the icon's exit animation
    // finishes on the side the user was swiping toward.
    if (entry.boundingClientRect.x !== entry.rootBounds.x) {
      item.dataset.swipeDirection = direction;
    }
  }, {
    root: track,
    threshold: [commitThreshold, activateThreshold],
  });

  // Return the handle without starting observation; the outer viewport observer
  // calls `observer.observe(content)` once the item is in view.
  const handle = {observer, content};
  swipeObservers.set(item, handle);
  return handle;
}

async function removeItem(item, content, direction, entry) {
  const opts = { duration: 300, easing: 'ease', fill: 'forwards' };

  const rect = entry.boundingClientRect;
  // Content's pixel offset from the track's left edge.
  const x = rect.x - entry.rootBounds.x;
  // Pixel distance the content needs to travel to be fully out of view.
  const translate = direction === 'left'
    ? rect.width - x
    : -(x + rect.width);

  // Use a combination of CSS transitions (for declarative styles) and
  // WAAPI animations (for computed values) to remove the element,
  // then await the completion of all of them.
  // Note: the content translate animation is important because the
  // height-collapse animation can otherwise finish before the browser's
  // smooth scroll-snap has scrolled the content fully off-screen.
  item.classList.add('is-removing');
  item.animate([{ height: `${rect.height}px` }, { height: '0px' }], opts);
  content.animate([{ translate: `${translate}px` }], opts);
  await Promise.allSettled(
    item.getAnimations({ subtree: true }).map((a) => a.finished),
  );

  // Safari has a scroll-latching bug: removing the node while the swipe
  // gesture's momentum is still resolving causes the next item (which
  // slides up into this one's place) to inherit the scroll and
  // immediately scroll itself off-screen. Detect Safari via
  // `GestureEvent` (a Safari-only API) and defer the actual DOM removal
  // until the gesture has fully settled. The 5s delay is conservative;
  // anything longer than the momentum tail is fine. Mark the row inert
  // so it can't be interacted with during the delay.
  if (globalThis.GestureEvent) {
    item.inert = true;
    setTimeout(() => item.remove(), 5000);
  } else {
    item.remove();
  }
}

function setupList(list) {
  // Observe items already in the list.
  for (const item of list.children) {
    if (item.matches('.SwipeableList-item')) {
      viewportObserver.observe(item);
    }
  }

  // Pick up items added later (initial render after data loads, infinite
  // scroll, server push). Removals don't need MutationObserver handling —
  // the commit branch above already unobserves the item before removing it
  // from the DOM.
  new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      for (const node of mutation.addedNodes) {
        if (node.nodeType === Node.ELEMENT_NODE &&
            node.matches('.SwipeableList-item')) {
          viewportObserver.observe(node);
        }
      }
    }
  }).observe(list, { childList: true });
}

document.querySelectorAll('.SwipeableList').forEach(setupList);

الخطوة 5: استخدام الإجراء والتصنيف المناسبَين لحالة الاستخدام

بالنسبة إلى الصيغ الأخرى غير الإزالة، يتغيّر فقط المحتوى المرئي ونص معالج الإرسال:

  • الأرشفة: خلفية خضراء/زرقاء، ورمز الأرشفة، ونقل العنصر إلى قائمة الأرشيف بدلاً من إزالته.
  • وضع علامة "مقروءة": خلفية خافتة، ورمز علامة صح، وتعديل حالة العنصر وإعادة عرضه (أو إزالة فئة .unread فقط).
  • تأجيل: خلفية زرقاء ورمز ساعة، لإخفاء الإشعار حتى وقت محدّد

لم تتغيّر آليات التمرير/المحاذاة/الملاحظة.

إجراءات مختلفة لكل اتجاه تمرير سريع

يمكن أن يعرض صف واحد أيضًا إجراءَين مختلفَين: أحدهما للتمرير السريع لليسار والآخر للتمرير السريع لليمين (على سبيل المثال، "أرشفة" على اليمين و"حذف" على اليسار)، بالطريقة التي تتبعها العديد من تطبيقات البريد الإلكتروني الأصلية. لا تتغيّر آليات التمرير والمحاذاة ورصد الإجراءات على الإطلاق، بل يتغيّر فقط ما يحدث في وقت تنفيذ الإجراء.

في الخطوة 4، يستدعي فرع الإرسال دائمًا removeItem(...). لإتاحة إجراءَين، اختَر المعالج استنادًا إلى direction:

if (ratio < commitThreshold) {
  const handler = direction === 'left' ? archiveItem : removeItem;
  handler(item, content, direction, entry);
  viewportObserver.unobserve(item);
  observer.disconnect();
  return;
}

ملاحظة حول التسمية: تمت تسمية removeItem للحالة المدمرة، ولكن الوظيفة التي يتم تشغيلها (تصغير ارتفاع الصف، وتحريك المحتوى خارج الشاشة، ثم إسقاط العقدة) هي في الواقع روتين عام "تم الانتهاء من هذا الصف، وتحريكه بعيدًا". وينطبق ذلك أيضًا على الأرشفة أو وضع علامة "مقروءة" أو التأجيل، إذ يختفي الصف من هذه القائمة في كلتا الحالتَين. إذا لم تزِل المعالجات أي شيء (على سبيل المثال، ينقل كلاهما العنصر إلى مكان آخر)، أعِد تسميته إلى شيء محايد مثل dismissItem حتى تتم قراءة الرمز بشكل صحيح.

لجعل الإجراءَين مختلفَين بصريًا، يمكنك رفع لون ورمز لكل اتجاه إلى عنصر القائمة، ثم تلوين المسار بتدرج مقسّم ورمزَي العنصر الزائف من المتغيّرات نفسها.

.SwipeableList-item {
  /* Action color + icon per swipe direction. `--left-*` is revealed
     when the user swipes RIGHT (e.g., archive); `--right-*` is revealed
     when the user swipes LEFT (e.g., delete). */
  --left-action-color: hsl(140 50% 40%);
  --left-action-icon: url("…archive svg…");
  --right-action-color: hsl(0 65% 50%);
  --right-action-icon: url("…trash svg…");
}

.SwipeableList-item.is-initialized .SwipeableList-track {
  /* Split reveal: left half of the scrollable area gets the left-action
     color, right half gets the right-action color. `background-attachment:
     local` makes the gradient's positioning area the scrollable area
     (3x the visible width), so the default size fills it and the 50% hard
     stop lines up exactly with the midpoint of the resting content column.
     The track's color also stays continuous behind the content as it
     translates off, so the leading-direction color shows the whole way. */
  background-image: linear-gradient(
    to right,
    var(--left-action-color) 50%,
    var(--right-action-color) 50%
  );
  background-attachment: local;
}

/* Per-side icons read from the same variables. */
.SwipeableList-item.is-initialized::before { background-image: var(--left-action-icon); }
.SwipeableList-item.is-initialized::after  { background-image: var(--right-action-icon); }

باستخدام هذا الإعداد، لن تحتاج الفواصل إلى لون خلفية خاص بها (يتولى التدرج اللوني للمسار عملية الكشف)، لذا يمكنك إسقاط قاعدة background-color في .SwipeableList-track::before, ::after من الخطوة 2 إذا كنت تستخدم هذا الخيار المزدوج الإجراء.

أفضل الممارسات والصعوبات

  • يجب استخدام حزمة snap من mandatory، وليس من proximity. باستخدام proximity، يمكن أن يظل الصف ممرَّرًا جزئيًا، ما يترك خلفية الإجراء نصف مرئية.
  • يجب ضبط السمة overscroll-behavior-x: none في المسار. بدون ذلك، يمكن أن يؤدي التمرير السريع إلى تفعيل إيماءة التنقّل للخلف في المتصفّح على أجهزة iOS/Android.
  • يجب الالتزام بحدّ معيّن قبل استقرار المحتوى في مكانه (مثلاً، commitThreshold ≈ 0.2) بدلاً من الانتظار حتى يختفي المحتوى بالكامل من الشاشة. يتيح ذلك بدء تأثير الإزالة المتحرّك أثناء الإيماءة، ما يمنحك شعورًا بأنّ الاستجابة أسرع بكثير من انتظار اكتمال الحركة.
  • يجب إعداد كل عنصر من إطار عرض خارجي IntersectionObserver بدلاً من ربط كل عنصر عند تحميل الصفحة. يؤدي ذلك إلى تجنُّب قراءة القيم التي تعتمد على التنسيق (clientWidth وما إلى ذلك) قبل عرض العناصر، ويحافظ على تناسب عدد المراقبين النشطين مع ما يمكن للمستخدم رؤيته فعليًا.
  • يجب استخدام MutationObserver في القائمة عند إضافة العناصر بشكل ديناميكي (العرض الأولي بعد تحميل البيانات، والتمرير اللا نهائي، وإرسال المعلومات إلى العميل قبل طلبها من الخادم). بدون ذلك، لن يتم ربط العناصر التي يتم إلحاقها بعد تحميل الصفحة.
  • لا تعتمد على pointerdown/pointermove/pointerup لإجراء عملية تحويل يدوية. ستفقد ميزات الزخم والارتداد وتسهيل استخدام لوحة المفاتيح والتعامل مع الحركة المنخفضة التي يوفّرها لك المتصفّح مجانًا.
  • يجب تأكيد الإجراءات المدمّرة عند الاقتضاء. بالنسبة إلى إجراء "الإزالة"، ننصحك بعرض رسالة مؤقتة للتراجع عن الإجراء بعد اكتمال التمرير السريع، لأنّ هذه الإيماءة سريعة وسهلة التفعيل عن طريق الخطأ.
  • يجب التأكّد من أنّ مسار التمرير قابل للتركيز عليه ويمكن الوصول إليه باستخدام لوحة المفاتيح، ومن توفّر إمكانية التركيز المرئي.
  • يجب توفير بدائل تسهّل الوصول إلى أي إجراءات ذات صلة يتم تفعيلها من خلال التمرير السريع (مثل زر مرئي أو قائمة سياق أو وضع التعديل).

استراتيجيات احتياطية

جميع الميزات الأحدث التي يتم استخدامها إما ليست أساسية للتجربة أو تحتوي على عناصر احتياطية قوية يمكن استخدامها بشكل موثوق الآن.

الاحتياطي لـ "overscroll-behavior"

لا حاجة إلى استخدام إجراء احتياطي لحالة الاستخدام هذه. على الرغم من أنّ السمة overscroll-behavior تواجه مشكلة في إمكانية التشغيل التفاعلي تظهر في الحاويات التي لا تتضمّن تجاوزًا قابلاً للتمرير، فإنّ المسار هنا يكون دائمًا قابلاً للتمرير أفقيًا (ثلاثة أعمدة بعرض كامل داخل حاوية بعرض %100)، لذا تعمل السمة بشكل متّسق على جميع المتصفّحات ويتم احتواء إيماءة التمرير السريع بشكل موثوق.

الاحتياطي لـ "scrollbar-width"

أشرطة التمرير المخفية هي تحسين مرئي، وليست الآلية التي تجعل ميزة "التمرير السريع للإزالة" تعمل. إذا لم يكن هدفك في Baseline يتضمّن scrollbar-width، سيظل الصف يتضمّن ميزة التمرير والمحاذاة ورصد الإجراءات والإزالة بشكل صحيح، ولكن قد تظهر تجربة غير متوافقة ببساطة شريط تمرير أفقي. إذا كان منتجك يتطلّب أشرطة تمرير مخفية في المتصفحات القديمة المستندة إلى WebKit، يمكنك إضافة قاعدة ::-webkit-scrollbar { display: none; } ذات نطاق ضيق لمسار التمرير السريع.

الاحتياطي لـ "scroll-initial-target"

إذا لم يتضمّن هدف Baseline السمة scroll-initial-target، يمكنك الانتقال إلى المحتوى آليًا داخل setupItem. الرصد باستخدام CSS.supports:

// Hoist the feature detect so the conditional `ResizeObserver` below can be
// skipped entirely when the property is supported.
const needsScrollWorkaround = !CSS.supports('scroll-initial-target', 'nearest');

function setupItem(item) {
  // ...existing setup from Step 4...

  if (needsScrollWorkaround) {
    track.scrollLeft = track.clientWidth;
  }

  // ...attach the inner IntersectionObserver, etc.
}

ترتيب المكالمات مهم. يجب تنفيذ الانتقال الآلي:

  1. AFTER .is-initialized is added to the list item (the class is what turns the track into a scroll container; setting scrollLeft on a non-scrollable element is a no-op).
  2. قبل إرفاق عنصر IntersectionObserver الداخلي من الخطوة 4. Otherwise the initial programmatic scroll past the left spacer will be observed as a "swipe" and immediately fire the commit handler.

إنّ ضبط الإعدادات من خلال مراقِب إطار العرض الخارجي (الخطوة 4) هو ما يجعل هذه الطريقة موثوقة: يتم تشغيل setupItem بعد عرض العنصر، لذا يعرض track.clientWidth قيمة حقيقية بدلاً من 0.

تعيد بعض المتصفّحات (لا سيما Safari) ضبط موضع الانتقال في حاوية المحاذاة كلما تم تغيير حجم المسار (إظهار/إخفاء شريط عناوين URL، وتغيير حجم إطار العرض، وطلبات البحث عن الحاويات، وما إلى ذلك). استخدِم ResizeObserver على كل مسار لإعادة تطبيق التمرير. يمكنك حظرها من خلال التحقّق من CSS.supports نفسه، فعندما يكون scroll-initial-target متاحًا، يتعامل المتصفّح مع استعادة موضع الانتقال عند تغيير الحجم بنفسه.

const trackResizeObserver = needsScrollWorkaround
  ? new ResizeObserver((entries) => {
      for (const entry of entries) {
        entry.target.scrollLeft = entry.target.clientWidth;
      }
    })
  : null;

function setupItem(item) {
  // ...existing setup...

  if (needsScrollWorkaround) {
    track.scrollLeft = track.clientWidth;
    trackResizeObserver.observe(track);
  }

  // ...attach the inner IntersectionObserver, etc.
}

يجب إلغاء تتبُّع المسار قبل تشغيل تأثيرات الحركة لارتفاع الصف في removeItem، وإلا سيؤدي تغيير الارتفاع إلى إعادة تفعيل دالة الاستدعاء لتغيير الحجم أثناء الإزالة. أضِف هذا إلى جانب viewportObserver.unobserve(item) الحالي في فرع التنفيذ:

if (ratio < commitThreshold) {
  removeItem(item, content, direction, entry);
  viewportObserver.unobserve(item);
  if (needsScrollWorkaround) trackResizeObserver.unobserve(track);
  observer.disconnect();
  return;
}