التوغّل في المكدّس

نظرة عامة

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

ينفّذ هذا الدليل حزمة البيانات على النحو التالي:

  • حاوية CSS أفقية لمحاذاة مواضع التمرير، حيث يكون كل عرض عبارة عن نقطة توقّف واحدة بالضبط. يؤدي التنقل لأسفل إلى إلحاق طريقة عرض جديدة والتمرير السلس إليها؛ يتم التعامل مع إيماءة التمرير للخلف بشكل أصلي بواسطة المتصفح، مما يمنح الزخم والسرعة وتتبع الانقطاع مجانًا بدون حدث مؤشر JavaScript.
  • متتبِّع أحداث scrollsnapchange في الحزمة يتم تشغيله عند تغيير هدف المحاذاة. يُستخدم هذا كالمصدر الوحيد للحقيقة في ما يتعلق بـ "تغيير طريقة العرض النشطة"، لذا فإنّ التمرير السريع والنقر والتمرير الآلي ومسارات popstate تتقارب جميعها في استدعاء واحد يحدّث inert، ويعيد التركيز، ويقلّم طرق العرض التي مرّرها المستخدم سريعًا، ويوفّق بين سجلّ المتصفّح.
  • يتيح التكامل بين pushState وpopstate إضافة إدخال في السجلّ لكل عملية تصفّح تفصيلي، وتفعيل إيماءة الرجوع على مستوى نظام التشغيل، وفتح الروابط لصفحات في التطبيق مباشرةً في العرض الصحيح.
  • تأثير حركي (اختياري) view() يتم تشغيله من خلال التمرير في كل طريقة عرض، وينتج عنه تأثير المنظر المتحرك + التعتيم + الظل المرتبط مباشرةً بإيماءة التمرير السريع، بحيث يتم تشغيل الحركة المرئية من خلال إصبع المستخدم، وليس من خلال تأثير حركي بيني.

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

التنفيذ

1. Markup

ملف HTML الثابت هو مجرد حاوية مكدّسة فارغة، ويتم إنشاء طرق العرض في JavaScript وإلحاقها أثناء تنقّل المستخدم.

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

كل عرض هو عنصر فرعي مباشر من .Stack-view من .Stack (هدف المحاذاة) مع برنامج تضمين .Stack-viewContent بداخله (حيث يتوفّر محتوى العرض، وحيث يتم تطبيق تحويل المنظر - راجع الخطوة 2). في أي وقت، يحتوي المكدّس على طريقة عرض واحدة لكل إدخال سجلّ نشط، من اليسار إلى اليمين بترتيب التوغّل. بعد أن يتعمّق المستخدم في مستويَين من الجذر، يظهر 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 في الخطوة 7، لذا لا تضبطها من أدوات إنشاء العرض.
  • تتم إزالة المشاهدات التي يتجاوزها المستخدم من نموذج DOM (أيضًا من خلال الخطوة 7) حتى لا يتجاوز عدد العناصر الفرعية في الحزمة currentDepth + 1. وتتم إعادة إنشائها عند الطلب من مسارات عناوين URL المخزّنة مؤقتًا إذا عاد التنقّل للأمام إليها.

2. الأنماط

أداة التمرير في المكدّس

المجموعة هي شبكة أفقية يكون فيها كل عرض فرعي بعرض الحاوية بالضبط، مع فرض 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; }
  }
}

ملاحظة: هذا التأثير شائع في تطبيقات الحزمة الأصلية الحديثة وهو نقطة بداية جيدة، ولكن يمكن تخصيص التأثيرات المرئية الدقيقة لتناسب أنماط الانتقال الحالية حسب الحاجة.

3- حالة الوحدة

يتتبّع المكدّس أربع حالات في نطاق الوحدة:

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

4. التوغّل

يؤدي التنقل لأسفل إلى أربعة أشياء بهذا الترتيب: دفع إدخال السجل، وإنشاء العرض الجديد، وإلحاقه، والتمرير السلس إليه. يستأنف معالج scrollsnapchange (الخطوة 7) من هناك بعد استقرار اللقطة.

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

5- التعامل مع النقرات وزر الرجوع

يمكنك اعتراض النقرات على الروابط داخل الحزمة وتحويلها إلى عمليات تصفية. يجب الحفاظ على سلوك مفتاح التعديل لكي يستمرّ النقر على مفتاح cmd أو النقر بزر الماوس الأوسط في فتح الرابط في علامة تبويب جديدة.

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

6. المزامنة من السجلّ (popstate)

يتم تنشيط popstate عندما يستخدم المستخدم زر الرجوع أو التقدم في المتصفح أو نظام التشغيل، أو عندما يستدعي JavaScript دالة 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});
}

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

عند تحميل الصفحة، قد يشير عنوان URL إلى صفحة في العمق (رابط مشترك أو إشارة مرجعية أو إعادة تحميل لصفحة في العمق). أنشئ أي طريقة عرض أولية تتطابق مع عنوان URL، سواء كانت جذرية أو مرتبطة برابط عميق، ولكن ليس كليهما، وأضِفها إلى الحزمة الفارغة، وأدخِل إدخال السجلّ ذي العمق 0، وشغِّل تمريرًا أوليًا باستخدام 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 كحلّ بديل، راجِع "استراتيجيات الحلول البديلة") كمصدر موثوق به لـ "تغيير العرض النشط"، وليس إحداثيات حدث التمرير. يُعدّ Snap commit الحدث الوحيد الذي يتم تشغيله باستمرار في مسارات التمرير السريع والنقر والتمرير الآلي وpopstate.
  • يجب تطبيق عمليات التحويل على عنصر فرعي لهدف المحاذاة، وليس على هدف المحاذاة نفسه. يؤدي التحويل في هدف المحاذاة إلى إعادة تغذية هندسة المحاذاة في حاوية الانتقال، وسيحدث خلل في شريط التمرير في منتصف الإيماءة.
  • يجب تطبيق inert على كل طريقة عرض باستثناء الطريقة المرئية حاليًا. بدون ذلك، يتسرّب التركيز والتنقّل عبر قارئ الشاشة إلى طرق العرض المخفية خلف تأثير التمرير السلس، وهي غير مرئية للمستخدمين المبصرين ولكنّها تمثّل عجزًا شديدًا في تسهيل الاستخدام.
  • يجب إدخال سجلّ في كل عملية انتقال إلى مستوى أدق ومعالجة popstate لكي تعمل إيماءة الرجوع على مستوى نظام التشغيل وأزرار الرجوع/التقديم في المتصفّح. هذا ما يجعل النمط يبدو وكأنه تطبيق أصلي.
  • يجب تسوية السجلّ من معالج active-view-changed عندما يؤدي التمرير السريع للخلف إلى عرض لا يتطابق عمقه مع currentDepth. بدون ذلك، يؤدي الرجوع اللاحق في نظام التشغيل إلى إعادة المستخدم إلى مكان غير متوقّع لأنّ مؤشر سجلّ المتصفّح غير متزامن مع ما يظهر على الشاشة.
  • يجب إزالة طرق العرض التي تجاوزها المستخدم من نموذج DOM. وإلا، يمكن أن تتراكم عشرات الأشجار الفرعية المنفصلة في جلسة التنقل لأسفل الطويلة. يكفي مسار عنوان URL المخزَّن مؤقتًا في entriesByDepth لإعادة إنشاء أيّ عرض إذا عاد إليه التنقّل للأمام.
  • يجب استدعاء .focus({preventScroll: true}) عند استعادة التركيز داخل الحزمة. يؤدي الإجراء التلقائي preventScroll: false إلى جعل المتصفّح يمرّر هدف التركيز إلى العرض، ما يؤدي إلى تعارض مع حاوية المحاذاة وقد يؤدي إلى توقّف المستخدم في منتصف المحاذاة.
  • يجب الحفاظ على إمكانية استخدام cmd/ctrl/النقر بزر الماوس الأوسط على الروابط الداخلية لكي تظل عناوين URL قابلة للمشاركة والفتح في علامة تبويب جديدة.
  • يجب استخدام behavior: 'instant' للانتقالات المتعددة الخطوات في السجلّ وللانتقالات المكانية للأمام في popstate (toIdx > fromIdx). وإلا، ستؤدي الانتقالات المتعددة الخطوات إلى تنشيط scrollsnapchange عند كل لقطة وسيطة وتنفيذ N جولة من العمليات غير النشطة/التركيز/السجلّ. غالبًا ما تكون أحداث popstate للأمام زائفة، إذ يتعامل متصفّح Safari على نظام التشغيل iOS مع التمرير السريع على الحافة على أنّه انتقال للأمام في المتصفّح حتى مع ضبط 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>. تتيح الروابط الحقيقية معاينة عنوان URL عند تمرير مؤشر الماوس فوقه، وإمكانية المشاركة، والنقر بزر الماوس الأوسط، ودور قارئ الشاشة، وتحسين محركات البحث مجانًا.
  • يجب تضمين زر رجوع صريح في كل طريقة عرض تفصيلية. تعمل إيماءة التمرير فقط عند اللمس - يحتاج مستخدمو لوحة المفاتيح والمؤشر وسطح المكتب إلى إمكانية مرئية.
  • لا تستدعِ history.pushState من معالج popstate، لأنّ ذلك يؤدي إلى دفع إدخالات جديدة أثناء محاولة المستخدم الرجوع إلى الصفحة السابقة، ما يؤدي إلى تعطُّل زر الرجوع في المتصفّح.
  • لا تستخدِم متتبِّع الأحداث scroll لتشغيل تأثير اختلاف المنظر عندما تكون الصور المتحركة المستندة إلى الانتقال متاحة. يتم تشغيل مسار CSS على أداة التركيب، بينما يتم تشغيل مستمع التمرير في JS على سلسلة التعليمات الرئيسية، وسيؤدي ذلك إلى إسقاط اللقطات بشكل واضح أثناء الإيماءة.
  • لا تُغيّر طرق العرض التي أزلتها من نموذج DOM بعد التمرير السريع للخلف. يتم التعامل مع entriesByDepth على أنّه السجلّ الأساسي: يحتوي الإدخال الذي تم تقليصه على view: null وتتم إعادة إنشائه عند الطلب في updateFromHistoryState.

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

الميزات التي قد تتطلّب إجراءات احتياطية هي scroll-snap-events وscroll-driven-animations، وكلاهما يتضمّن إجراءات احتياطية فعّالة أو قصص تحسين تدريجي، ويمكن استخدامهما بأمان في حالة الاستخدام هذه:

أحداث محاذاة مواضع التمرير

يُعدّ الحدث scrollsnapchange الطريقة الأفضل لرصد "تغيير العرض النشط"، أي مستمع واحد في الحزمة، يتم تشغيله مرة واحدة بالضبط لكل عملية تثبيت. في المتصفّحات التي لا تتضمّن هذه الميزة، يمكن استخدام polyfill للحصول على التأثير نفسه من خلال 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);
}

صور متحركة مستندة إلى الانتقال

إنّ تأثيرات التمرير المنزلق / التعتيم / الظل المستندة إلى الانتقال هي تحسين تدريجي على مستوى التنقّل الأساسي. يحدّ الشرط @supports (animation-timeline: view()) في CSS (الموضّح في الخطوة 2) من استخدام الصور المتحركة في المتصفّحات المتوافقة، وفي أي مكان آخر، يتم الانتقال بين نقاط التثبيت بدون أي تأثيرات انتقالية. يعمل المكوّن بشكل كامل بدون تأثير اختلاف المنظر، إذ لا تزال ميزات المحاذاة ومزامنة السجلّ وإدارة التركيز و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})`;
    }
  });
}