کاوش بیشتر پشته‌ای

نمای کلی

دریل‌داون پشته‌ای یک الگوی پیمایش سلسله‌مراتبی است که در برنامه‌های تلفن همراه رایج است و در آن، فعال کردن یک پیوند، نمای تمام‌صفحه جدیدی را روی نمای قبلی قرار می‌دهد. محتوای نما توسط برنامه تعریف می‌شود — زیرصفحه تنظیمات، رشته‌ای در فید، پوشه‌ای در مرورگر فایل، صفحه جزئیات در گالری، و غیره. کاربر با تند کشیدن نمای فعلی به سمت راست و خارج از صفحه یا با تک‌ضرب زدن روی دکمه برگشت، به صفحه قبلی برمی‌گردد. سابقه مرورگر همگام‌سازی‌شده باقی می‌ماند تا اشاره «برگشت» در سطح سیستم‌عامل، پیوندهای عمیق، و پیمایش جلو/عقب همگی به‌طور منسجم کار کنند.

این راهنما پشته را به‌صورت زیر پیاده‌سازی می‌کند:

  • یک محفظه CSS افقی با میزان کردن پیمایش که هر نما دقیقاً یک توقف میزان کردن است. با مته‌کاری، نمای جدیدی اضافه می‌شود و به‌آرامی به آن پیمایش می‌شود؛ اشاره تند به عقب به‌طور بومی توسط مرورگر مدیریت می‌شود و ردیابی تکانه، سرعت، و وقفه را به‌طور رایگان و بدون اشاره‌گر-رویداد جاوا اسکریپت ارائه می‌دهد.
  • یک شنونده رویداد scrollsnapchange در پشته که هنگام تغییر هدف چسباندن فعال می‌شود. این به‌عنوان منبع واحد حقیقت برای «نمای فعال تغییر کرد» استفاده می‌شود — بنابراین تند کشیدن، کلیک کردن، پیمایش برنامه‌ای، و مسیرهای popstate همگی در یک فراخوانی برگشتی همگرا می‌شوند که inert را به‌روز می‌کند، تمرکز را بازیابی می‌کند، نماهایی را که کاربر با تند کشیدن رد کرده است هرس می‌کند، و سابقه مرورگر را تطبیق می‌دهد.
  • ادغام pushState / popstate به‌گونه‌ای که هر بررسی دقیق یک ورودی سابقه اضافه می‌کند، اشاره «برگشت» در سطح سیستم‌عامل کار می‌کند، و پیوندهای عمیق مستقیماً در نمای درست باز می‌شوند.
  • یک پویانمایی (اختیاری) پیمایش‌محور view() در هر نما که جلوه اختلاف منظر + کم‌نور + سایه را مستقیماً به اشاره تند کشیدن مرتبط می‌کند، بنابراین حرکت قابل‌مشاهده توسط انگشت کاربر هدایت می‌شود، نه یک میان‌یابی.

این رویکرد نسبت‌به پویانمایی‌های transform مبتنی بر جاوا اسکریپت ترجیح داده می‌شود زیرا سازوکار چسباندن به کاربر امکان می‌دهد مستقیماً با اشاره موقعیت پانل را کنترل کند (انگشت کاربر آن را هدایت می‌کند، نه یک tween) و با الگوهای تعاملی که کاربران از برنامه‌های بومی تلفن همراه انتظار دارند مطابقت دارد.

پیاده‌سازی

۱. نشانه‌گذاری

HTML ایستا فقط یک محفظه پشته خالی است؛ نماها در جاوا اسکریپت ساخته می‌شوند و با پیمایش کاربر اضافه می‌شوند.

<div class="Stack">
  <!-- Intentionally empty. The initial view is appended by JavaScript
       at init time (step 8). -->
</div>

هر نما یک .Stack-view فرزند مستقیم از .Stack (هدف snap) با یک .Stack-viewContent wrapper در داخل آن است (جایی که محتوای نما زندگی می‌کند و جایی که تبدیل اختلاف منظر اعمال می‌شود — مرحله ۲ را ببینید). در هر لحظه، پشته یک نما برای هر ورودی سابقه فعال دارد، از چپ به راست به ترتیب جزئیات. پس‌از اینکه کاربر دو سطح از ریشه را بررسی کرد، DOM ارائه‌شده به این شکل خواهد بود:

<div class="Stack">
  <!-- Root view. Has whatever content makes sense as the entry point
       of this section of the app. No back button — there is nothing
       behind the root in the stack. -->
  <div class="Stack-view" inert>
    <div class="Stack-viewContent">
      <!-- Root content; includes <a href> links that drill in. -->
    </div>
  </div>

  <!-- First-level drill-down view. The user got here by activating a
       link in the root view. -->
  <div class="Stack-view" inert>
    <div class="Stack-viewContent">
      <header>
        <!-- DO include a back button. The swipe gesture only works on
             touch — keyboard and pointer users need an explicit control. -->
        <button class="back" aria-label="Back"></button>
        <!-- Title / breadcrumb / etc. -->
      </header>
      <main>
        <!-- View content; may include further drill-down <a href> links. -->
      </main>
    </div>
  </div>

  <!-- Second-level drill-down view. Currently visible — no `inert`
       attribute. Same shape as the first-level view. -->
  <div class="Stack-view">
    <div class="Stack-viewContent">
      <header>
        <button class="back" aria-label="Back"></button>
        <!-- ... -->
      </header>
      <main><!-- ... --></main>
    </div>
  </div>
</div>

نکته‌ها:

  • همه نماها به‌جز نمای فعلی قابل‌مشاهده، دارای مشخصه inert هستند. این مورد به‌طور خودکار توسط گرداننده scrollsnapchange در مرحله ۷ اعمال/ برداشته می‌شود — آن را از سازندگان نمای خود تنظیم نکنید.
  • نماهایی که کاربر با تند کشیدن به عقب از آن‌ها عبور می‌کند از DOM حذف می‌شوند (همچنین با مرحله ۷) تا پشته هرگز فراتر از currentDepth + 1 فرزند رشد نکند. اگر پیمایش روبه‌جلو به آن‌ها برگردد، درصورت درخواست از مسیرهای نشانی وب ذخیره‌شده در حافظه نهان بازسازی می‌شوند.

۲. سبک‌ها

پیمایش‌کننده پشته

پشته یک شبکه افقی است که در آن هر نمای فرزند دقیقاً به‌اندازه عرض محفظه است و CSS scroll-snap یک نما در هر میزان کردن را اعمال می‌کند. این همان چیزی است که به اشاره تند کشیدن برای برگشتن حس بومی می‌دهد.

.Stack {
  /* Use dvh so the height tracks the dynamic viewport on mobile, where
     the address bar can show/hide. svh would clip during the address bar
     animation; vh leaks under it. */
  height: 100dvh;

  /* Lay views out left-to-right, each one full-width, so horizontal
     scrolling moves between them one at a time. */
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: 100%;
  grid-template-rows: 100%;
  overflow-x: auto;

  /* `mandatory` guarantees the stack always settles fully on a view —
     never half-way between two. */
  scroll-snap-type: x mandatory;
  /* Prevent the swipe-back gesture from chaining into the browser's
     own history-back gesture (iOS, some Android) or the page's vertical
     scroll. The user is navigating the stack, not the page. */
  overscroll-behavior-x: none;
}

/* Hide the visual scrollbar — the snap and the parallax are the
   affordances; a horizontal scrollbar would look out of place. */
.Stack::-webkit-scrollbar {
  display: none;
}

/* MANDATORY: Opt into smooth programmatic scrolling via CSS, gated on
   prefers-reduced-motion. JS code calls scrollTo/scrollBy with
   behavior: 'auto' which defers to this rule, so the OS-level reduced-
   motion preference automatically downgrades to instant scrolling
   without any per-call JS branching. */
@media (prefers-reduced-motion: no-preference) {
  .Stack {
    scroll-behavior: smooth;
  }
}

.Stack-view {
  scroll-snap-align: start;
  /* `always` prevents the user from blowing through more than one view
     per gesture, so depth changes always happen one step at a time. */
  scroll-snap-stop: always;
}

/* MANDATORY: A separate inner element is required for the parallax
   transform below. Applying transforms directly to the snap target
   (.Stack-view) would feed back into the scroll container's snap
   geometry and the scroller would jump mid-gesture. */
.Stack-viewContent {
  width: 100%;
  height: 100%;
  background-color: #fff;
  /* Each view scrolls its own content vertically, independent of the
     stack's horizontal scroll. */
  overflow-y: auto;
}

جلوه «پشته» (اختلاف منظر / کم‌نور / سایه)

پویانمایی view(inline) پیمایش‌محور پیشرفت هر نما را ازطریق پیمایشگر پشته ردیابی می‌کند و اختلاف منظر + کم‌نور شدن را برای نمای خروجی اعمال می‌کند، به‌علاوه سایه‌ای روی نماهای جزئیات ورودی اعمال می‌کند تا به‌عنوان «کارت‌هایی» که روی نمای قبلی چیده شده‌اند خوانده شوند.

/* MANDATORY: Wrap the animation block in @supports. Browsers without
   scroll-driven animations still parse the @keyframes and would
   apply the `to` state as a static style, leaving every view
   permanently transformed. The @supports gate confines the animation
   to browsers where it actually animates. */
@supports (animation-timeline: view()) {
  .Stack-viewContent {
    /* view(inline) tracks this element's progress through its nearest
       scrollable ancestor on the inline (x) axis. */
    animation: parallax linear both;
    animation-timeline: view(inline);
    /* Only animate the EXIT phase — when this view is being covered
       by a deeper one. During its own entry the view stays at rest,
       so the fresh content is fully bright and in position throughout. */
    animation-range: exit 0% exit 100%;
  }

  /* Drill-down views (everything except the root) also get a shadow on
     their left edge during the transition so they feel like cards
     stacking over the previous view. */
  .Stack-view:not(:first-child) .Stack-viewContent {
    animation: parallax linear both, shadow-fade linear both;
    animation-timeline: view(inline), view(inline);
    /* parallax: only exit (the view sliding back as a deeper one comes in).
       shadow-fade: entry through exit (visible the whole time the view is
       transitioning, not when it's at rest). */
    animation-range: exit 0% exit 100%, entry 0% exit 100%;
  }

  @keyframes parallax {
    /* translateX(75%) and brightness(0.8) are examples — adjust to taste. */
    to {
      transform: translateX(75%);
      filter: brightness(0.8);
    }
  }

  @keyframes shadow-fade {
    /* Shadow ramps in during entry, holds across the middle of the
       gesture, and ramps out during exit — so it's only visible while
       the view is mid-transition, not when at rest. */
    0%, 100% { box-shadow: 0 0 1.5rem #0000; }
    25%, 75% { box-shadow: 0 0 1.5rem #0004; }
  }
}

توجه: این جلوه در برنامه‌های پشته بومی مدرن محبوب است و نقطه شروع خوبی است، اما جلوه‌های بصری دقیق را می‌توان برای مطابقت با سبک‌های انتقال موجود درصورت نیاز سفارشی کرد.

۳. وضعیت واحد

پشته چهار بخش از وضعیت را در محدوده واحد ردیابی می‌کند:

const stack = document.querySelector('.Stack');

// Reference to the root view DOM element. The Stack starts empty, so
// this is null until the root view is created — either at init (step 8,
// when the URL is '/') or later by synthesizeRootEntry() (when the user
// landed on a deep link and then navigates back). Held as a mutable
// reference because other code (the scrollsnapchange handler, init, etc.)
// uses identity comparisons against it.
let rootView = null;

// Tracks which element to restore focus to when the user swipes back
// into a previous view. Keyed by the view element itself so entries
// are garbage-collected automatically when the view is pruned.
const returnFocus = new WeakMap();

// Maps history depth -> {urlPath, view}. We MUST maintain this map
// ourselves because the History API does not expose state for entries
// other than the current one — so when a view is pruned on swipe-back,
// we still need to remember which URL it represented in case the user
// later forward-navigates back into it.
const entriesByDepth = new Map();

// Tracked manually because history.state on a popstate event tells us
// the destination depth but not where we came from. We need both to
// compute the direction (back vs forward) and the distance.
let currentDepth = 0;

به‌علاوه سه کمک‌کننده مختص برنامه — تنها جاهایی که مسیریابی و رندر کردن نمای برنامه شما به آن متصل می‌شود:

// Resolve a URL path to the data your app needs to render the
// corresponding drill-down view, or return null for paths this section
// of the app does not handle (the root path '/', external links,
// unknown routes). resolveUrl() is for drill-down routes only — the
// root view is rendered separately by createRootView() below.
function resolveUrl(urlPath) {
  // Replace with your routing logic. For example, match `/view/:id`
  // and look the id up in your app state.
}

// Build the root (home) view of the stack. Application-specific content;
// preserve the .Stack-view / .Stack-viewContent wrapper structure (the
// inner element is required for the parallax — see step 2) and DO NOT
// render a back button — the root view has nothing behind it in the
// stack.
function createRootView() {
  const view = document.createElement('div');
  view.className = 'Stack-view';
  view.innerHTML = `
    <div class="Stack-viewContent">
      <!-- Root content. Include <a href> elements pointing at URL paths
           that resolveUrl() accepts, to enable drill-down from here. -->
    </div>
  `;
  return view;
}

// Build a drill-down view DOM element from the resolved route data.
// Customize the inner content freely, but DO preserve the .Stack-view
// / .Stack-viewContent wrapper structure and DO include a back button
// (the swipe gesture only works on touch).
function createDrillDownView(routeData) {
  const view = document.createElement('div');
  view.className = 'Stack-view';
  view.innerHTML = `
    <div class="Stack-viewContent">
      <header>
        <button class="back" aria-label="Back"></button>
        <!-- Title, breadcrumb, or other view chrome derived from
             routeData. -->
      </header>
      <main>
        <!-- View body, also from routeData. Include further <a href>
             elements pointing at URL paths that resolveUrl() also
             accepts, to enable additional drill-downs from this view. -->
      </main>
    </div>
  `;
  return view;
}

function getCurrentUrlPath() {
  return location.pathname;
}

۴. کاوش بیشتر

یک drill-down چهار کار را به این ترتیب انجام می‌دهد: یک ورودی تاریخچه را push می‌کند، نمای جدید را می‌سازد، آن را append می‌کند، و به آن smooth-scroll می‌کند. وقتی عکس فوری آماده شد، گرداننده scrollsnapchange (مرحله ۷) از آنجا ادامه می‌دهد.

function drillDown(urlPath) {
  const routeData = resolveUrl(urlPath);
  if (!routeData) return;

  const newDepth = currentDepth + 1;
  // Push BEFORE creating the view so the URL is correct if anything
  // observing history (analytics, etc.) reads it during view creation.
  history.pushState({depth: newDepth}, '', urlPath);

  // pushState truncates forward entries in real browser history;
  // mirror that truncation in our depth map so we don't hold references
  // to views the user can no longer reach.
  for (const d of entriesByDepth.keys()) {
    if (d >= newDepth) entriesByDepth.delete(d);
  }
  currentDepth = newDepth;

  const newView = createDrillDownView(routeData);
  stack.appendChild(newView);
  entriesByDepth.set(newDepth, {urlPath, view: newView});

  // Scroll one viewport-width to the right. behavior: 'auto' defers to
  // the CSS `scroll-behavior` set in step 2, which is smooth unless
  // prefers-reduced-motion is set. The snap container locks onto the
  // new view; the scrollsnapchange listener (step 7) fires when the
  // snap settles.
  stack.scrollBy({left: stack.clientWidth, behavior: 'auto'});
}

۵. مدیریت کلیک و دکمه برگشت

کلیک‌های پیوند را در پشته رهگیری کنید و آن‌ها را به جزئیات بیشتر تبدیل کنید. Preserve modifier-key behavior so cmd/middle-click still opens the link in a new tab.

stack.addEventListener('click', (e) => {
  // Back button: defer to goBack() (defined below), which handles both
  // the normal in-app case and the deep-link case.
  if (e.target.closest('.back')) {
    goBack();
    return;
  }

  // Drill-down link.
  const link = e.target.closest('a');
  if (!link || !stack.contains(link)) return;
  // Let the browser handle modified clicks so users can open links
  // in new tabs / windows. e.button !== 0 filters out middle-clicks.
  if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return;

  const urlPath = new URL(link.href).pathname;
  const parentView = link.closest('.Stack-view');
  // If the URL isn't handled by this section of the app (resolveUrl
  // returns null), fall through so the browser navigates normally.
  if (!resolveUrl(urlPath) || !parentView) return;

  e.preventDefault();
  // Record which link the user activated so focus can be restored to
  // it when they swipe (or click) back into this view.
  returnFocus.set(parentView, link);
  drillDown(urlPath);
});

// Going back is usually just history.back(), but there's an important
// edge case: when the user lands directly on a deep-linked URL, there
// is no in-app history entry behind it. Calling history.back() in that
// situation would take them out of the app entirely. MANDATORY: detect
// this case and synthesize a root entry instead, so an in-app Back from
// a deep link lands on the root view and the platform Back from there
// returns the user to where they came from.
function goBack() {
  const atDeepLinkRoot = currentDepth === 0
    && entriesByDepth.get(0)?.view !== rootView;
  if (atDeepLinkRoot) {
    synthesizeRootEntry();
  } else {
    // history.back() fires popstate, which routes through
    // updateFromHistoryState (step 6) and scrolls the stack — the
    // same path a swipe-back converges on.
    history.back();
  }
}

function synthesizeRootEntry() {
  // Push a new history entry pointing at the root URL. This becomes the
  // entry the user "came from"; the original deep-linked entry is now
  // behind us, so platform Back from the root view will return there.
  const newDepth = currentDepth + 1;
  history.pushState({depth: newDepth}, '', '/');

  // Create the root view if it doesn't exist yet (we landed on a deep
  // link and never needed it before now), and insert it at the LEFT end
  // of the stack. Adjust scrollLeft by one viewport width so the user's
  // view doesn't visually jump — they should still be looking at the
  // deep-linked view until the scroll animation below runs.
  if (!rootView) {
    rootView = createRootView();
    stack.prepend(rootView);
    stack.scrollLeft += stack.clientWidth;
  }
  entriesByDepth.set(newDepth, {urlPath: '/', view: rootView});

  // Now scroll to the new entry (the root view). updateFromHistoryState
  // smooth-scrolls one step left, the parallax plays, and
  // scrollsnapchange fires when the root view settles.
  updateFromHistoryState(history.state);
}

‫۶. همگام‌سازی از سابقه (popstate)

popstate زمانی فعال می‌شود که کاربر از دکمه عقب یا جلو مرورگر/ سیستم‌عامل استفاده کند، یا زمانی که جاوا اسکریپت history.back() / .go() را فراخوانی کند. این گرداننده تنها مسیری است که پشته را در پاسخ به تغییر سابقه پیمایش می‌کند.

window.addEventListener('popstate', (event) => {
  updateFromHistoryState(event.state);
});

function updateFromHistoryState(state, behaviorOverride) {
  const newDepth = state?.depth ?? 0;
  const urlPath = getCurrentUrlPath();

  // Ensure entriesByDepth has an entry for the destination depth.
  // If the URL changed (e.g. forward-nav into a previously-pruned
  // view), clear the cached view reference so the loop below rebuilds.
  const entry = entriesByDepth.get(newDepth) ?? {view: null};
  if (entry.urlPath !== urlPath) {
    entry.urlPath = urlPath;
    entry.view = urlPath === '/' ? rootView : null;
  }
  entriesByDepth.set(newDepth, entry);

  // Rebuild any views between root and the destination that were
  // pruned earlier (when the user swiped back past them). Without
  // this, forward-navigating to a previously-pruned view would have
  // no element to scroll to.
  for (let d = 0; d <= newDepth; d++) {
    const e = entriesByDepth.get(d);
    if (!e || e.view) continue;
    const routeData = resolveUrl(e.urlPath);
    if (!routeData) continue;
    const rebuilt = createDrillDownView(routeData);
    stack.appendChild(rebuilt);
    e.view = rebuilt;
  }

  currentDepth = newDepth;

  const targetView = entriesByDepth.get(newDepth)?.view;
  if (!targetView) return;

  // Compare destination index against current scroll position so we
  // can bail if they're already aligned. This is reached when the
  // scrollsnapchange handler below calls history.go() to sync history
  // after a swipe-back that already completed visually — there's
  // nothing more to scroll.
  const toIdx = [...stack.children].indexOf(targetView);
  const fromIdx = Math.round(stack.scrollLeft / stack.clientWidth);
  if (fromIdx === toIdx) return;

  // Pick a scroll behavior:
  //   - multi-step jumps (e.g. history.go(-3)): 'instant' to skip
  //     intermediate snap points — otherwise smooth-scrolling would
  //     fire scrollsnapchange for each one and do N rounds of
  //     state-transition work for no reason.
  //   - rightward (forward) single-step: 'instant'. Browser-forward is
  //     rare on the web and is often spurious (e.g. iOS Safari treats
  //     edge swipes as forward navigation, even with overscroll-behavior
  //     set). An instant swap reads as "snap" rather than a misleading
  //     drilldown animation the user didn't ask for. The user-initiated
  //     drill-down path (drillDown, step 4) is unaffected — it calls
  //     scrollBy directly and never reaches this code.
  //   - leftward (back) single-step: 'auto' so the CSS `scroll-behavior`
  //     (smooth unless prefers-reduced-motion is set — see step 2)
  //     applies. Back is the common, expected case and benefits from
  //     the animation.
  // NOTE: "forward" here means spatial direction (toIdx > fromIdx),
  // NOT depth direction. synthesizeRootEntry (step 5) pushes a new
  // depth but scrolls LEFT to the root view, which correctly reads as
  // back-style (smooth).
  const forward = toIdx > fromIdx;
  const multiStep = Math.abs(toIdx - fromIdx) > 1;
  const behavior = behaviorOverride ?? (forward || multiStep ? 'instant' : 'auto');
  stack.scrollTo({left: toIdx * stack.clientWidth, behavior});
}

‫۷. scrollsnapchange: منبع واحد حقیقت

پس‌از هر ثبت پرش — چه با تند کشیدن، کلیک، یا پیمایش برنامه‌ای راه‌اندازی شده باشد — مرورگر یک رویداد scrollsnapchange را در محفظه پیمایش فعال می‌کند، و عنصر جدیداً پرش‌شده به‌عنوان event.snapTargetInline (برای پرش افقی) نمایش داده می‌شود. قرار دادن همه انتقال‌های وضعیت در این یک گرداننده چیزی است که مسیر تند کشیدن، مسیر کلیک، و مسیر popstate را منسجم نگه می‌دارد.

گرداننده در یک تابع مستقل onActiveViewChanged استخراج می‌شود تا بازگشت (به «استراتژی‌های بازگشت» در زیر مراجعه کنید) بتواند بدون تکرار منطق، از آن مجدداً استفاده کند.

function onActiveViewChanged(currentView) {
  // Walk the stack in DOM order to update each view's role:
  //  - Views at or before currentView stay in the DOM but get
  //    `inert` (except currentView) so focus, pointer events, and
  //    AT navigation cannot leak into views hidden behind the
  //    parallax.
  //  - Views after currentView are unreachable (the user swiped
  //    back past them) so we drop them from the DOM to free memory.
  //    Their urlPath stays in entriesByDepth so a later forward
  //    navigation can rebuild the view from scratch.
  let seenCurrent = false;
  for (const view of [...stack.children]) {
    if (seenCurrent) {
      for (const e of entriesByDepth.values()) {
        if (e.view === view) e.view = null;
      }
      view.remove();
    } else {
      // MANDATORY: inert non-current views. Without this, tabbing
      // and screen-reader navigation can reach content hidden behind
      // the parallax — a severe accessibility failure that's
      // invisible to sighted users.
      view.toggleAttribute('inert', view !== currentView);
      if (view === currentView) seenCurrent = true;
    }
  }

  // If the visible view's depth doesn't match `currentDepth`, the
  // user got here by swiping (not clicking) — sync history so the
  // browser back/forward buttons stay coherent with what's on screen.
  let currentViewDepth;
  for (const [d, e] of entriesByDepth) {
    if (e.view === currentView) currentViewDepth = d;
  }
  if (currentViewDepth !== undefined && currentViewDepth !== currentDepth) {
    // history.go fires popstate, which re-enters updateFromHistoryState.
    // That call's fromIdx === toIdx check bails out without scrolling.
    history.go(currentViewDepth - currentDepth);
  }

  // Restore focus on the now-active view:
  //  - If we recorded which link the user activated to drill out
  //    of this view, return focus there so a swipe-back lands them
  //    exactly where they left off.
  //  - Otherwise (a freshly-pushed drill-down view), move focus to
  //    the back button so keyboard users have an obvious next action.
  //  - preventScroll is REQUIRED: without it, .focus() scrolls the
  //    snap container to bring the focused element into view, which
  //    fights the snap and can land the user mid-snap.
  const stored = returnFocus.get(currentView);
  if (stored) {
    stored.focus({preventScroll: true});
    returnFocus.delete(currentView);
  } else if (currentView !== rootView) {
    currentView.querySelector('.back')?.focus({preventScroll: true});
  }
}

stack.addEventListener('scrollsnapchange', (event) => {
  // snapTargetInline is the element that was just snapped to on the
  // inline (horizontal) axis. For this stack — where each view is one
  // horizontal snap stop — that's the new active view.
  onActiveViewChanged(event.snapTargetInline);
});

وقتی صفحه بار می‌شود، ممکن است نشانی وب ازقبل به نمای عمیق اشاره کند (پیوند هم‌رسانی‌شده، نشانک، بازآوری در صفحه عمیق). هر نمای اولیه‌ای را که با نشانی وب مطابقت دارد بسازید — ریشه یا پیوند عمیق، اما هرگز هر دو — آن را به پشته خالی اضافه کنید، ورودی سابقه عمق-۰ را بذرپاشی کنید، و یک گذر پیمایش اولیه با behavior: 'instant' اجرا کنید تا اختلاف منظر در اولین رنگ‌آمیزی متحرک نشود.

const initialUrlPath = getCurrentUrlPath();
const initialRouteData = resolveUrl(initialUrlPath);

// Build the initial view: a drill-down view if the URL maps to one,
// otherwise the root view. Whichever it is, that's the only view in
// the stack right now — the other will be created lazily by
// synthesizeRootEntry (step 5) or drillDown (step 4) if the user
// navigates to it.
let initialView;
if (initialRouteData) {
  initialView = createDrillDownView(initialRouteData);
} else {
  rootView = createRootView();
  initialView = rootView;
}
stack.appendChild(initialView);

entriesByDepth.set(0, {urlPath: initialUrlPath, view: initialView});
// replaceState attaches a `depth` to the entry the user landed on, so
// any subsequent pushState / popstate has a base depth to count from.
history.replaceState({depth: 0}, '');

updateFromHistoryState(history.state, 'instant');

روال‌های مطلوب

  • باید از رویداد scrollsnapchange (با بازگشت IntersectionObserver — به «استراتژی‌های بازگشت» مراجعه کنید) به‌عنوان منبع حقیقت برای «تغییر نمای فعال» استفاده کنید، نه مختصات رویداد پیمایش. ثبت پرش تنها رویدادی است که به‌طور مداوم در مسیرهای تند کشیدن، کلیک کردن، پیمایش برنامه‌ای، و popstate فعال می‌شود.
  • باید تبدیل‌ها را روی فرزند هدف چسباندن اعمال کنید، نه روی خود هدف چسباندن. تبدیل در هدف چسباندن به هندسه چسباندن محفظه پیمایش بازخورد می‌دهد و پیمایشگر در میان اشاره دچار اشکال می‌شود.
  • باید inert را برای همه نماها به‌جز نمای فعلی اعمال کنید. بدون این، تمرکز و پیمایش صفحه‌خوان به نماهای پنهان‌شده در پشت اختلاف منظر نشت می‌کند — برای کاربران بینا نامرئی است اما یک نقص جدی در دسترس‌پذیری است.
  • باید در هر ریزکاوی، ورودی سابقه را فشار دهید و popstate را مدیریت کنید تا اشاره «برگشت» در سطح سیستم‌عامل و دکمه‌های عقب/جلو مرورگر کار کنند. این همان چیزی است که باعث می‌شود الگو مانند یک برنامه بومی احساس شود.
  • باید وقتی تند کشیدن به عقب روی نمایی قرار می‌گیرد که عمق آن با currentDepth مطابقت ندارد، سابقه را از کنترل‌کننده active-view-changed تطبیق دهید. بدون این، «برگشت» بعدی سیستم‌عامل کاربر را به جایی غیرمنتظره برمی‌گرداند زیرا مکان‌نمای سابقه مرورگر با آنچه روی صفحه است همگام‌سازی نشده است.
  • باید نماهایی را که کاربر با تند کشیدن از DOM رد کرده است هرس کنید. درغیراین‌صورت، یک جلسه طولانی بررسی جزئیات می‌تواند ده‌ها زیردرخت جداشده را جمع‌آوری کند. مسیر نشانی وب ذخیره‌شده در حافظه نهان در entriesByDepth برای بازسازی هر نمایی کافی است، اگر پیمایش روبه‌جلو به آن برگردد.
  • باید .focus({preventScroll: true}) را هنگام بازیابی تمرکز در پشته فراخوانی کنید. preventScroll: false پیش‌فرض باعث می‌شود مرورگر هدف تمرکز را به نمای پیمایش منتقل کند، که با محفظه چسبیدن تداخل دارد و می‌تواند کاربر را در میان چسبیدن قرار دهد.
  • باید cmd/ctrl/کلیک میانی روی پیوندهای داخلی را حفظ کنید تا نشانی‌های وب همچنان قابل‌هم‌رسانی و باز شدن در برگه جدید باشند.
  • باید از behavior: 'instant' برای پرش‌های چندمرحله‌ای در سابقه و برای انتقال‌های popstate فضایی-روبه‌جلو (toIdx > fromIdx) استفاده کنید. درغیراین‌صورت، پرش‌های چندمرحله‌ای در هر نقطه چسبیدن میانی scrollsnapchange را فعال می‌کنند و N دور کار غیرفعال/تمرکز/سابقه انجام می‌دهند. حالت‌های پاپ فوروارد اغلب جعلی هستند — iOS Safari تند کشیدن لبه را به‌عنوان فوروارد مرورگر درنظر می‌گیرد، حتی با تنظیم overscroll-behavior-x: none، و تعویض فوری بسیار کمتر از پویانمایی «دریل‌داون» که کاربر شروع نکرده است گمراه‌کننده است. مسیر جزئیات بیشتر که کاربر شروع کرده است (drillDown) تحت تأثیر قرار نمی‌گیرد زیرا مستقیماً پیمایش می‌کند، نه ازطریق popstate.
  • باید prefers-reduced-motion را رعایت کنید: scroll-behavior: smooth را فقط در داخل @media (prefers-reduced-motion: no-preference) اعلام کنید و scrollTo / scrollBy را با behavior: 'auto' (نه 'smooth') فراخوانی کنید تا اولویت سطح سیستم‌عامل بدون انشعاب JS برای هر تماس اعمال شود. کدگذاری ثابت behavior: 'smooth' تنظیمات کاربر را دور می‌زند.
  • باید عناصر واقعی <a href> را به‌عنوان راه‌اندازهای مته‌ای رندر کنید، نه <button onclick> یا <div>. لنگرهای واقعی پیش‌نمایش نشانی وب را هنگام نگه داشتن نشانگر روی آن‌ها، قابلیت هم‌رسانی، کلیک وسط، نقش صفحه‌خوان، و بهینه‌سازی موتور جستجو را به‌طور رایگان دریافت می‌کنند.
  • باید در هر نمای ریزنگری، دکمه برگشت صریح اضافه کنید. اشاره تند کشیدن فقط در حالت لمسی کار می‌کند — کاربران صفحه‌کلید، اشاره‌گر، و رایانه به یک امکان رؤیت‌پذیر نیاز دارند.
  • history.pushState را از گرداننده popstate فراخوانی نکنید — این کار ورودی‌های جدید را درحالی‌که کاربر سعی می‌کند به عقب برگردد، اضافه می‌کند و دکمه برگشت مرورگر را خراب می‌کند.
  • وقتی پویانمایی‌های پیمایش‌محور دردسترس است، اختلاف منظر را با شنونده رویداد scroll هدایت نکنید. مسیر CSS روی ترکیب‌کننده اجرا می‌شود؛ شنونده پیمایش JS روی رشته اصلی اجرا می‌شود و درطول اشاره، فریم‌ها را به‌طور قابل‌مشاهده‌ای حذف می‌کند.
  • نباید نماهایی را که پس‌از تند کشیدن به عقب از DOM برداشته‌اید تغییر دهید. Treat entriesByDepth as the canonical record: a pruned entry has view: null and is rebuilt on demand in updateFromHistoryState.

راهبردهای جایگزین

ویژگی‌هایی که ممکن است به جایگزین نیاز داشته باشند عبارت‌اند از scroll-snap-events و scroll-driven-animations که هر دو دارای جایگزین‌های قوی یا داستان‌های ارتقای پیش‌رو هستند و برای این مورد استفاده ایمن هستند:

رویدادهای میزان کردن پیمایش

رویداد scrollsnapchange تمیزترین راه برای تشخیص «تغییر نمای فعال» است — یک شنونده در پشته، دقیقاً یک‌بار در هر ثبت سریع فعال می‌شود. در مرورگرهای فاقد آن، همین جلوه را می‌توان با IntersectionObserver شبیه‌سازی کرد که هر نما را برای رؤیت‌پذیری کامل در پشته تماشا می‌کند. جایگزین به همان تابع onActiveViewChanged که مسیر اصلی استفاده می‌کند ارسال می‌شود، بنابراین همه منطق انتقال حالت در یک مکان باقی می‌ماند.

// MANDATORY when supporting browsers that haven't shipped scroll-snap-events
// yet. Check `HTMLElement.prototype` (not `window` or `document`) — the
// event handler IDL attribute is added to the prototype when the feature
// is supported, regardless of whether any element has the handler set.
if (!('onscrollsnapchange' in HTMLElement.prototype)) {
  const viewObserver = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      // threshold:1 only fires for fully-visible entries, but the
      // observer also emits a "leaving" entry per view that drops below
      // ratio 1. Filter to the entering side, which is the snap-commit
      // moment we're trying to detect.
      if (entry.intersectionRatio === 1) {
        onActiveViewChanged(entry.target);
      }
    }
  }, {root: stack, threshold: 1});

  // Auto-observe every .Stack-view as it's added to the stack, and stop
  // observing as it's removed. Using a MutationObserver lets the primary
  // code (drillDown, updateFromHistoryState, synthesizeRootEntry, init)
  // stay free of fallback wiring.
  new MutationObserver((mutations) => {
    for (const m of mutations) {
      for (const node of m.addedNodes) {
        if (node.classList?.contains('Stack-view')) viewObserver.observe(node);
      }
      for (const node of m.removedNodes) {
        if (node.classList?.contains('Stack-view')) viewObserver.unobserve(node);
      }
    }
  }).observe(stack, {childList: true});

  // Catch up to any views already in the stack at the time this code
  // runs (typically the initial view appended in step 8).
  for (const view of stack.children) viewObserver.observe(view);
}

پویانمایی‌های پیمایش‌محور

جلوه اختلاف منظر / کم‌نور / سایه مبتنی بر پیمایش، یک ارتقای پیش‌رو در بالای هسته پیمایش است. دروازه CSS @supports (animation-timeline: view()) (نشان‌داده‌شده در مرحله ۲) پویانمایی را به مرورگرهای پشتیبانی‌کننده محدود می‌کند؛ در جاهای دیگر، نماها به‌سادگی بین توقف‌های پرشی بدون انتقال جابه‌جا می‌شوند. این مؤلفه بدون اختلاف منظر کاملاً کاربردی است — چسباندن، همگام‌سازی سابقه، مدیریت تمرکز، و inert همگی همچنان کار می‌کنند.

اگر برای اهداف پایه قدیمی‌تر به جایگزین اختلاف منظر نیاز است، یک شنونده scroll به پشته متصل کنید و یک ویژگی سفارشی CSS بنویسید که پیشرفت هر نما را در درگاه پیمایش توصیف کند، سپس transform و filter را از آن ویژگی هدایت کنید:

if (!CSS.supports('animation-timeline: view()')) {
  stack.addEventListener('scroll', () => {
    const viewWidth = stack.clientWidth;
    for (const view of stack.children) {
      // Progress: 0 when this view is centered, 1 when it has fully
      // exited to the left. Matches the @keyframes mapping above.
      const offsetLeft = view.offsetLeft - stack.scrollLeft;
      const progress = Math.min(1, Math.max(0, -offsetLeft / viewWidth));
      const content = view.querySelector('.Stack-viewContent');
      content.style.transform = `translateX(${progress * 75}%)`;
      content.style.filter = `brightness(${1 - progress * 0.2})`;
    }
  });
}