Przesuń, by usunąć

Wzorce przesunięcia w celu usunięcia są powszechne w aplikacjach mobilnych, ale ich prawidłowe wdrożenie w internecie może być trudne. Za pomocą przewijania z przyciąganiem w CSS możesz utworzyć płynną, naturalną interakcję przesuwania, która jest bezpośrednio połączona z silnikiem przewijania przeglądarki. Zapewnia to wysoką wydajność i pęd oparty na fizyce bez konieczności korzystania ze złożonej biblioteki gestów JavaScriptu.

Ten sam wzorzec działa w przypadku każdego przesunięcia z pojedynczym działaniem (usuń, archiwizuj, oznacz jako przeczytane, odłóż). Zmienia się oprawa wizualna, ale nie mechanika.

Jeśli chcesz, aby przesunięcie w poziomie otwierało zestaw przycisków poleceń lub menu boczne bez automatycznego wykonywania działania podczas przesuwania, zapoznaj się z artykułem Przesuwanie w celu wyświetlenia.

Jak to zrobić?

Komponent ma 2 warstwy: listę (<ul>) i znajdujące się na niej elementy. Każdy element składa się z zewnętrznego elementu <li>, wewnętrznego paska przewijania (kontenera przewijania z punktami przyciągania) i elementu treści (widocznego wiersza). Interfejs działania (ikona kosza, etykieta archiwum itp.) znajduje się po obu stronach treści. Lista ma współdzielone połączenia (leniwa konfiguracja elementów, pobieranie nowo dodanych elementów). Każdy element ma własne wykrywanie przesunięcia.

Krok 1. Oznacz listę ścieżką i treścią

<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>

Krok 2. Skonfiguruj ścieżkę jako poziomy kontener przyciągania z 3 punktami przyciągania

Ścieżka ma 3 kolumny o pełnej szerokości: lewy odstęp (::before), treść i prawy odstęp (::after). Przyciąganie do odstępu oznacza, że treść jest całkowicie poza ekranem, co jest pozycją spoczynkową po przesunięciu.

Konfiguracja ścieżki jest ukryta za klasą .is-initialized elementu listy. Zanim JS uaktualni wiersz, element jest renderowany jako zwykła treść bez poziomego paska przewijania. Klasa jest dodawana w kroku 4, po podłączeniu kodu JavaScript, który wykrywa przesunięcia. Dzięki temu użytkownik nie będzie mógł przesunąć palcem, zanim funkcja nie będzie gotowa.

.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;
}

Krok 3. Dodaj ikony działań do elementu listy

Kolor działania znajduje się na elementach dystansowych (krok 2). Ikona działania znajduje się na samym elemencie listy, zakotwiczona przy lewej i prawej krawędzi wiersza.

Poniższe informacje dotyczące umiejscowienia, rozmiaru i ruchu to sugestie na początek, a nie wymagania. Dostosuj rozmiar ikony, marginesy krawędzi, skalę progu, czas trwania przejścia, a nawet wybór pseudoelementów i rzeczywistych węzłów DOM, aby dopasować je do projektu. Jedynym wymaganiem technicznym jest to, aby ikona znajdowała się za treścią (aby treść mogła ją zakryć przed przesunięciem) i wewnątrz elementu listy (aby nie przewijała się razem z elementem dystansowym). Wszystko inne to kwestia gustu.

/* 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;
}

Krok 4. Wykrywanie gestu zatwierdzenia za pomocą IntersectionObserver

Użyj IntersectionObserver z korzeniem w ścieżce, obserwując treść. Gdy użytkownik przesuwa palcem, współczynnik przecięcia treści ze ścieżką spada. Używamy 2 progów:

  • activateThreshold: wysoki współczynnik (np. 0,8). Gdy widoczna część treści spadnie poniżej tej wartości, użytkownik przekroczy punkt aktywacji wizualnej. Przełącz opcję wyskakującej ikony.
  • commitThreshold: niski współczynnik (np. 0,2). Gdy widoczna część spadnie poniżej tej wartości, użytkownik jest uznawany za zaangażowanego. Animacja usuwania powinna rozpocząć się natychmiast, bez czekania na zakończenie gestu przyciągania. Zwijany wiersz płynnie dopasowuje się do ruchu przesuwania, co daje wrażenie większej responsywności niż czekanie na zakończenie ruchu przed reakcją.

W tym miejscu zajmiemy się jeszcze 2 kwestiami:

  • Leniwa konfiguracja poszczególnych elementów: pojedynczy zewnętrzny element IntersectionObserver zakorzeniony w widocznym obszarze steruje konfiguracją i uruchamianiem/zatrzymywaniem wewnętrznych obserwatorów przesunięcia. Elementy są podłączane tylko wtedy, gdy po raz pierwszy pojawiają się na ekranie, a w przypadku elementów, które znikają z ekranu, obserwator przesunięcia jest wstrzymywany. Dzięki temu liczba aktywnych obserwatorów jest ograniczona i nie dochodzi do odczytywania wartości zależnych od układu (takich jak clientWidth) przed wyrenderowaniem elementu.
  • Elementy dynamiczne: rzeczywiste listy powiększają się z czasem (początkowe renderowanie, przewijanie nieskończone, komunikat push z serwera). MutationObserver w <ul> rejestruje wszystkie nowo dodane elementy w zewnętrznym obserwatorze.
// 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);

Krok 5. Użyj działania i etykiety, które pasują do Twojego przypadku użycia

W przypadku wariantów innych niż usunięcie zmieniają się tylko elementy wizualne i treść modułu obsługi zatwierdzenia:

  • Archiwizuj: zielone/niebieskie tło, ikona archiwum, przeniesienie elementu na listę archiwum zamiast jego usunięcia.
  • Oznacz jako przeczytane: przyciemnione tło, ikona znacznika wyboru, aktualizacja stanu elementu i ponowne renderowanie (lub po prostu usunięcie klasy .unread).
  • Odłóż: niebieskie tło, ikona zegara, ukryj do wybranego czasu.

Mechanizmy przewijania, przyciągania i obserwacji pozostają bez zmian.

różne działania w zależności od kierunku przesuwania,

W jednym wierszu można też udostępnić 2 różne działania: jedno dla przesunięcia w lewo, a drugie dla przesunięcia w prawo (np. „archiwizuj” po prawej stronie, „usuń” po lewej stronie), tak jak robi to wiele natywnych aplikacji pocztowych. Mechanizmy przewijania, przyciągania i wykrywania zatwierdzenia nie zmieniają się wcale – zmienia się tylko to, co dzieje się w momencie zatwierdzenia.

W kroku 4 gałąź zatwierdzania zawsze wywołuje funkcję removeItem(...). Aby obsługiwać 2 działania, wybierz moduł obsługi na podstawie direction:

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

Uwaga dotycząca nazewnictwa: nazwa removeItem odnosi się do przypadku destrukcyjnego, ale funkcja, którą uruchamia (zmniejszenie wysokości wiersza, przesunięcie treści poza ekran, a następnie usunięcie węzła), jest w rzeczywistości ogólną procedurą „ten wiersz jest gotowy, animuj go”. Działa to tak samo w przypadku archiwizowania, oznaczania jako przeczytane i odkładania na później – wiersz i tak znika z tej listy. Jeśli Twoje moduły obsługi nie usuwają niczego (np.oba przenoszą element w inne miejsce), zmień nazwę na neutralną, np. dismissItem, aby kod był poprawnie odczytywany.

Aby wizualnie odróżnić te 2 działania, dodaj do elementu listy kolor i ikonę dla każdego kierunku, a następnie pomaluj ścieżkę za pomocą podzielonego gradientu i 2 ikon pseudoelementów z tych samych zmiennych.

.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); }

Przy tej konfiguracji elementy dystansowe nie potrzebują już własnego koloru tła (gradient ścieżki obsługuje odsłanianie), więc jeśli używasz tego wariantu z podwójnym działaniem, możesz usunąć regułę background-color w .SwipeableList-track::before, ::after z kroku 2.

Sprawdzone metody i pułapki

  • TAK – używaj ikony mandatory, a nie proximity. W przypadku proximity wiersz może być częściowo przewinięty, pozostawiając tło działania częściowo widoczne.
  • USTAW overscroll-behavior-x: none na ścieżce. Bez tego zbyt długie przesunięcie może aktywować gest przechodzenia wstecz w przeglądarce na iOS lub Androidzie.
  • ZDECYDUJ SIĘ na próg przed zakończeniem przyciągania (np. commitThreshold ≈ 0.2), zamiast czekać, aż treść całkowicie zniknie z ekranu. Dzięki temu animacja usuwania rozpoczyna się podczas gestu, co daje znacznie lepsze wrażenie responsywności niż czekanie na zakończenie gestu.
  • DO drive per-item setup from an outer viewport IntersectionObserver rather than wiring every item up at page load. Dzięki temu unikniesz odczytywania wartości zależnych od układu (clientWidth itp.) przed wyrenderowaniem elementów, a liczba aktywnych obserwatorów będzie proporcjonalna do tego, co użytkownik może faktycznie zobaczyć.
  • UŻYWAJ elementu MutationObserver na liście, gdy elementy są dodawane dynamicznie (początkowe renderowanie po wczytaniu danych, przewijanie nieskończone, komunikat push z serwera). Bez niego elementy dołączone po wczytaniu strony nie zostaną połączone.
  • NIE polegaj na pointerdown/pointermove/pointerup w przypadku ręcznego przekształcania. Utracisz pęd, przyciąganie, ułatwienia dostępu klawiatury i obsługę ograniczonego ruchu, które przeglądarka zapewnia bezpłatnie.
  • POTWIERDZAJ działania destrukcyjne, gdy jest to konieczne. W przypadku opcji „Usuń” rozważ wyświetlenie po przesunięciu powiadomienia z możliwością cofnięcia. Gest jest szybki i łatwo go przypadkowo wywołać.
  • ZADBAJ o to, aby pasek przewijania można było zaznaczyć i obsługiwać za pomocą klawiatury oraz aby był on widoczny po zaznaczeniu.
  • DO provide accessible alternatives for any relevant actions triggered by the swipe (e.g., a visible button, context menu, or edit mode).

Strategie zastępcze

Wszystkie używane nowsze funkcje nie są kluczowe dla działania aplikacji lub mają niezawodne rozwiązania zastępcze, których można obecnie używać.

Kreacja zastępcza dla: overscroll-behavior

W tym przypadku użycia nie jest potrzebna kreacja zastępcza. Chociaż overscroll-behavior ma problem ze współdziałaniem, który objawia się w kontenerach bez przewijania, ścieżka jest tu zawsze przewijana w poziomie (3 kolumny o pełnej szerokości w kontenerze o szerokości 100%), więc właściwość zachowuje się spójnie w różnych przeglądarkach, a gest przesunięcia jest niezawodnie obsługiwany.

Kreacja zastępcza dla: scrollbar-width

Ukryte paski przewijania to ulepszenie wizualne, a nie mechanizm, który umożliwia usuwanie przez przesunięcie. Jeśli Twój cel Baseline nie obejmuje scrollbar-width, wiersz nadal będzie się przewijać, przyciągać, wykrywać zatwierdzenie i usuwać prawidłowo. W nieobsługiwanej wersji może po prostu wyświetlać się poziomy pasek przewijania. Jeśli Twój produkt wymaga ukrytych pasków przewijania w starszych przeglądarkach opartych na WebKit, możesz dodać regułę ::-webkit-scrollbar { display: none; } o wąskim zakresie dla ścieżki przesunięcia.

Kreacja zastępcza dla: scroll-initial-target

Jeśli cel Baseline nie zawiera elementu scroll-initial-target, przewiń ścieżkę do treści automatycznie w elemencie setupItem. Wykrywanie za pomocą 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.
}

Kolejność wywołań ma znaczenie. Przewijanie automatyczne MUSI zostać uruchomione:

  1. AFTER .is-initialized jest dodawany do elementu listy (klasa przekształca ścieżkę w kontener przewijania; ustawienie scrollLeft na elemencie, którego nie można przewijać, nie działa).
  2. PRZED podłączeniem wewnętrznego elementu IntersectionObserver z kroku 4. W przeciwnym razie początkowe automatyczne przewinięcie poza lewy element dystansowy zostanie uznane za „przesunięcie” i natychmiast uruchomi moduł obsługi zatwierdzania.

Konfiguracja sterowania z zewnętrznego obserwatora widocznego obszaru (krok 4) zapewnia niezawodność: funkcja setupItem jest uruchamiana po wyrenderowaniu elementu, więc track.clientWidth zwraca rzeczywistą wartość, a nie 0.

Niektóre przeglądarki (zwłaszcza Safari) resetują też pozycję przewijania kontenera przyciągania, gdy zmienia się rozmiar ścieżki (wyświetlanie/ukrywanie paska adresu URL, zmiana rozmiaru widocznego obszaru, zapytania dotyczące kontenera itp.). Aby ponownie zastosować przewijanie, użyj ResizeObserver na każdej ścieżce. Zabezpiecz to za pomocą tego samego testu CSS.supports – gdy scroll-initial-target jest obsługiwane, przeglądarka sama obsługuje przywracanie przewijania podczas zmiany rozmiaru.

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.
}

Przestań obserwować ścieżkę, zanim animacja wysokości wiersza zostanie uruchomiona w: removeItem. W przeciwnym razie zmiana wysokości spowoduje ponowne wywołanie zwrotne zmiany rozmiaru podczas usuwania. Dodaj to obok istniejącego elementu viewportObserver.unobserve(item) w gałęzi zatwierdzenia:

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