کشوی پیمایش

نمای کلی

کشوی پیمایش پانلی است که از لبه نمای هدف روی محتوای صفحه می‌لغزد و همه چیز پشت آن را کم‌نور می‌کند. این پانل با دکمه راه‌انداز باز می‌شود و با تند کشیدن پانل به خارج از صفحه، تک‌ضرب زدن روی پس‌زمینه کم‌نور، یا فشار دادن کلید Escape بسته می‌شود.

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

  • عنصر popover="manual" به لایه بالایی ارتقا یافته است تا پانل و ::backdrop آن روی هر عنصر دیگری در صفحه قرار بگیرند، صرف‌نظر از بافت پشته‌سازی.
  • یک محفظه پیمایش افقی با دو توقف CSS scroll-snap — یکی برای «باز» و دیگری برای «بسته» — تا اشاره تند کشیدن به‌طور بومی توسط مرورگر مدیریت شود. این ویژگی ردیابی تکانه، سرعت، و وقفه را به‌طور رایگان و بدون کد رویداد اشاره‌گر جاوا اسکریپت ارائه می‌دهد.
  • پویانمایی پیمایش‌محور که ماتی پس‌زمینه را به موقعیت پیمایش گره می‌زند، بنابراین وقتی کاربر پانل را می‌کشد، تاریکی به‌آرامی محو و ظاهر می‌شود.
  • یک IntersectionObserver در پانل که تشخیص می‌دهد چه زمانی به‌طور کامل وارد نمای پورتال شده یا از آن خارج شده است، و از این لحظات برای به‌روزرسانی تمرکز، aria-expanded، و inert استفاده می‌کند.

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

پیاده‌سازی

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

کشوی برنامه یک پاپ‌اور تکی است که حاوی یک پیمایشگر افقی است که «برگه» قابل‌مشاهده را دربر می‌گیرد. دکمه راه‌انداز در محتوای صفحه قرار دارد.

<!-- popover="manual" is REQUIRED. Do not use popover="auto" or "hint". -->
<div class="Drawer" id="drawer" popover="manual">
  <div class="Drawer-scroller">
    <nav class="Drawer-sheet" tabindex="-1">
      <!-- tabindex="-1" makes the sheet programmatically focusable so we
           can move focus into it when the drawer opens, without adding it
           to the natural tab order. -->
      <ul>
        <li><a href="/page-1">Page 1</a></li>
        <li><a href="/page-2">Page 2</a></li>
        <!-- ... -->
      </ul>
    </nav>
  </div>
</div>

<main>
  <header>
    <!-- aria-controls links the trigger to the drawer; aria-expanded
         reflects the current state for assistive tech. -->
    <button id="drawer-open"
            aria-label="Menu"
            aria-expanded="false"
            aria-controls="drawer">
      <!-- MANDATORY: Inline decorative SVGs MUST define aria-hidden="true" -->
      <svg aria-hidden="true" viewBox="0 0 24 24">...</svg>
    </button>
  </header>
  <!-- Page content. -->
</main>

۲. سبک‌ها

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

پنجره بالاپر باید کل نمای دید را بپوشاند تا ::backdrop آن کل صفحه را تیره کند و سطح تند کشیدن از لبه‌ای به لبه دیگر گسترش یابد. The default user-agent popover styles (centered, auto-sized, bordered) get in the way and must be reset.

.Drawer {
  /* min() caps the sheet width on large screens but on a phone leaves
     a 20% peek of page content visible, which is the affordance that
     tells the user they can tap outside to dismiss. */
  --drawer-width: min(20em, 80dvw);

  /* Custom property driven by the scroll-driven animation below.
     0 = drawer fully closed (transparent backdrop).
     1 = drawer fully open (visible backdrop). */
  --drawer-backdrop: 0;

  /* Reset UA popover style that would constrain the element. */
  width: auto;
  height: auto;
  background: transparent;
  border: 0;
  overflow: visible;
}

/* Style the popover's ::backdrop to achieve the overlay effect and
   provide visual affordances indicating that the rest of the page is inert */
.Drawer::backdrop {
  background: #000;
  /* Use calc() to limit the opacity range so the content beneath is visible */
  opacity: calc(var(--drawer-backdrop) / 2);
}

ساختن سطح تند کشیدن با میزان کردن پیمایش

پیمایشگر شبکه‌ای افقی است که از ناحیه نمایش عریض‌تر است: ستون ۱ حاوی برگه است (عرض --drawer-width)، ستون ۲ فاصله‌انداز شبه‌عنصر خالی به عرض ناحیه نمایش است. با چسباندن دو ستون به یکدیگر، کشو باز و بسته می‌شود.

.Drawer-scroller {
  position: relative;
  display: grid;
  /* Sheet on the left, full-viewport spacer on the right. The user
     scrolls between the two snap stops to open and close. */
  grid-template-columns: var(--drawer-width) 100%;

  overflow-x: scroll;
  /* Stop the swipe from chaining into the page's vertical scroll
     when the user reaches either snap edge. */
  overscroll-behavior: none;
  scrollbar-width: none;
  /* `mandatory` guarantees the drawer always settles fully open or
     fully closed — never half-open after a partial swipe. */
  scroll-snap-type: x mandatory;
}

/* Enable smooth scrolling natively, but only if the user has not
   requested reduced motion. */
@media (prefers-reduced-motion: no-preference) {
  .Drawer-scroller {
    scroll-behavior: smooth;
  }
}

/* The empty spacer that creates the "closed" snap stop. */
.Drawer-scroller::after {
  content: '';
  scroll-snap-align: end;
  /* Open the popover already scrolled to this stop (drawer off-screen),
     so the JS only needs to scroll to the open position to
     animate it in. */
  scroll-initial-target: nearest;
}

.Drawer-sheet {
  display: grid;
  grid-template-rows: auto 1fr;
  /* Use `svh` (small viewport height) — not `vh` or `dvh` — so the
     sheet height does not jump when the iOS Safari address bar
     resizes mid-swipe. */
  height: 100svh;

  background: #333;
  color: #fff;
  overflow-y: auto;
  scroll-snap-align: start;
  scrollbar-width: none;
}

ماتی پساویز را به موقعیت پیمایش گره می‌زند

پویانمایی پیمایش‌محور --drawer-backdrop را از ۱ (باز) تا ۰ (بسته) در سراسر محدوده پیمایشگر نگاشت می‌کند، بنابراین پساویز کاملاً همگام با کشیدن محو می‌شود و ظاهر می‌شود.

/* MANDATORY: Wrap this entire block in @supports. Browsers that don't
   support animation-timeline still parse the @keyframes and would
   apply the animation's `0%` value (--drawer-backdrop: 1) at all
   times, leaving the backdrop permanently opaque. The @supports gate
   ensures the animation is only registered where it actually works. */
@supports (animation-timeline: scroll()) {
  .Drawer {
    /* timeline-scope lets .Drawer reference a scroll-timeline that
       is defined on its descendant (the scroller). Without this, the
       timeline name is not visible to the .Drawer element. */
    timeline-scope: --drawer-fade;
    animation: fade-drawer-backdrop linear both;
    animation-timeline: --drawer-fade;
  }

  .Drawer-scroller {
    /* The horizontal scroll position of this element drives the
       timeline named `--drawer-fade`. */
    scroll-timeline: --drawer-fade x;
  }

  /* @property is REQUIRED. Without registering --drawer-backdrop with
     a `<number>` syntax, the browser treats it as a string and cannot
     interpolate it — the backdrop would jump from 0 to 1 with no
     fade. */
  @property --drawer-backdrop {
    syntax: '<number>';
    inherits: true;
    initial-value: 0;
  }

  @keyframes fade-drawer-backdrop {
    /* Scroll position 0 = drawer fully open = backdrop visible. */
    0% { --drawer-backdrop: 1 }
    /* Scroll position 100% = drawer fully closed = backdrop hidden. */
    100% { --drawer-backdrop: 0 }
  }
}

۳. باز و بسته کردن کشو

باز کردن دو مرحله دارد: ارتقای پاپ‌اور به لایه بالایی، سپس پیمایش برگه به نمای دید. بستن یک مرحله است: به فاصله‌انداز برگردید؛ یک ناظر (مرحله ۴) وقتی برگه کاملاً از صفحه خارج شد، بالاپر را پنهان می‌کند.

const drawer = document.getElementById('drawer');
const openBtn = document.getElementById('drawer-open');
const scroller = drawer.querySelector('.Drawer-scroller');
const sheet = drawer.querySelector('.Drawer-sheet');

function openDrawer() {
  // Show the popover first so the element is in the top layer before
  // we trigger any scrolling. `scroll-initial-target` (set on the
  // ::after spacer) places the initial scroll position at the closed
  // stop, so the drawer enters the top layer already off-screen.
  drawer.showPopover();

  // Scroll the sheet into view. The `behavior: 'auto'` option defers
  // to the CSS `scroll-behavior` property, which will be smooth unless
  // the user prefers reduced motion. Snap takes over at the end and
  // locks the drawer fully open.
  scroller.scrollTo({left: 0, behavior: 'auto'});
}

function closeDrawer() {
  // Scroll back to the spacer. Do NOT call hidePopover() here —
  // doing so would remove the element from the top layer mid-animation
  // and the close animation would not be visible. The
  // IntersectionObserver in step 4 hides the popover once the sheet
  // has actually left the viewport.
  scroller.scrollTo({left: scroller.offsetWidth, behavior: 'auto'});
}

۴. تشخیص وضعیت باز و بسته بودن

از IntersectionObserver در برگه — نه موقعیت پیمایش — به‌عنوان منبع حقیقت برای وضعیت کشو استفاده کنید. ناظر بدون توجه به نحوه حرکت برگه (تند کشیدن کاربر، پیمایش برنامه‌ای، تراز کردن) فعال می‌شود، بنابراین همه مسیرهای رد کردن در یک فراخوانی برگشتی همگرا می‌شوند.

function onDrawerOpened() {
  // Mark the rest of the page inert so keyboard and screen-reader
  // users cannot tab into content hidden behind the drawer.
  document.querySelector('main').inert = true;
  openBtn.setAttribute('aria-expanded', 'true');
  // Move focus into the drawer for keyboard users.
  sheet.focus();
}

function onDrawerClosed() {
  // Hide the popover only after the close animation completes,
  // so the slide-out is visible to the user.
  drawer.hidePopover();
  document.querySelector('main').inert = false;
  openBtn.setAttribute('aria-expanded', 'false');
}

// Treat "any pixel of the sheet visible inside the popover root" as
// "open enough to count as not closed". This threshold is intentionally
// tiny so the closed callback only fires once the sheet is truly gone.
const visibleThreshold = 1 / window.innerWidth;

const observer = new IntersectionObserver(
  (entries) => {
    // During programmatic scrolling the observer can deliver multiple
    // entries in one batch. Only the most recent describes the
    // current state; earlier entries are intermediate positions.
    const entry = entries.at(-1);
    if (entry.intersectionRatio < visibleThreshold) onDrawerClosed();
    if (entry.intersectionRatio === 1) onDrawerOpened();
  },
  // root: drawer makes the popover element the intersection root,
  // so the ratio reflects the sheet's visibility within the popover
  // (i.e. how much of it has been swiped on-screen).
  {root: drawer, threshold: [visibleThreshold, 1]},
);
observer.observe(sheet);

۵. گرداننده‌های راه‌انداز و رد کردن را سیم‌کشی کنید

// Open trigger.
openBtn.addEventListener('click', openDrawer);

// Light-dismiss: a tap on the dimmed area (anywhere inside the
// popover but outside the sheet) closes the drawer. We implement
// this manually because popover="manual" disables the browser's
// built-in light-dismiss (which would also fire mid-swipe — see step 1).
drawer.addEventListener('click', (event) => {
  if (!sheet.contains(event.target)) closeDrawer();
});

// Escape key. Listen on document because focus may be inside the
// drawer when the user presses Escape.
document.addEventListener('keydown', (event) => {
  if (event.key === 'Escape') closeDrawer();
});

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

میانای برنامه‌سازی کاربردی popover، پویانمایی پیمایش‌محور که پساویز را محو می‌کند، و scroll-initial-target ارتقاهای پیش‌رو با جایگزین‌های ساده‌ای هستند که اگر پشتیبانی گسترده مرورگر لازم باشد به‌راحتی می‌توان آن‌ها را پیاده‌سازی کرد.

Backdrop fade fallback (no animation-timeline support):

با CSS.supports('animation-timeline: scroll()') شناسایی کنید و اگر پشتیبانی نمی‌شود، --drawer-backdrop را از شنونده رویداد scroll بنویسید. بلوک CSS @supports در مرحله ۲ تضمین می‌کند که فریم‌های کلیدی هرگز در مرورگرهای پشتیبانی‌نشده اعمال نمی‌شوند، بنابراین مقدار جاوا اسکریپت تنها نویسنده است.

if (!CSS.supports('animation-timeline: scroll()')) {
  scroller.addEventListener('scroll', () => {
    // Same mapping as the @keyframes: 0 scroll = 1 (open),
    // sheet-width scroll = 0 (closed).
    const ratio = 1 - scroller.scrollLeft / sheet.offsetWidth;
    drawer.style.setProperty('--drawer-backdrop', ratio);
  });
}

جایگزین موقعیت پیمایش اولیه (بدون پشتیبانی از scroll-initial-target):

Detect with CSS.supports('scroll-initial-target', 'nearest') and inside openDrawer(), jump-scroll the scroller to the closed position immediately after showPopover(). بدون این، کشو فوراً در موقعیت باز و بدون پویانمایی لغزشی ظاهر می‌شود.

async function openDrawer() {
  drawer.showPopover();

  if (!CSS.supports('scroll-initial-target', 'nearest')) {
    // Jump-scroll to the closed stop so the scroll below
    // animates the drawer in from off-screen.
    scroller.scrollTo({left: scroller.offsetWidth, behavior: 'instant'});
    // Wait two animation frames for the jump-scroll to commit.
    // A single rAF is not enough — the second `scrollTo` would
    // cancel the first before the browser has a chance to apply it.
    await new Promise((r) =>
      requestAnimationFrame(() => requestAnimationFrame(r))
    );
  }

  scroller.scrollTo({left: 0, behavior: 'auto'});
}

‫@property بازگشت (بدون مشخصه سفارشی ثبت‌شده):

@property فقط به این دلیل لازم است که پویانمایی مبتنی بر پیمایش --drawer-backdrop را بین فریم‌های کلیدی درون‌یابی می‌کند — بدون ثبت، این ویژگی به‌عنوان یک رشته درنظر گرفته می‌شود و بدون محو شدن بین ۰ و ۱ می‌پرد. اگر بازگشت پویانمایی مبتنی بر پیمایش بالا برقرار باشد، آن جاوا اسکریپت یک رشته عددی جدید را در هر قاب پیمایش در --drawer-backdrop می‌نویسد و هرگز درون‌یابی نمی‌کند، بنابراین نیازی به بازگشت @property جداگانه نیست زیرا همه مرورگرهایی که از پویانمایی‌های مبتنی بر پیمایش پشتیبانی می‌کنند، از @property نیز پشتیبانی می‌کنند.

جایگزین Popover API (بدون پشتیبانی از مشخصه popover):

چون این مؤلفه از popover="manual" استفاده می‌کند و رد کردن را کاملاً از جاوا اسکریپت پیاده‌سازی می‌کند، به رفتارهای تعریف‌کننده میانای برنامه‌سازی کاربردی popover (رد کردن با کلیک در خارج از عنصر، ویژگی popovertarget، مدیریت Escape با مدیریت لایه بالایی، یا مدیریت تمرکز) وابسته نیست. تنها ویژگی‌های popover که واقعاً استفاده می‌کند، ارتقای لایه بالایی (ازطریق showPopover()) و شبه‌عنصر ::backdrop است.

اگر به پشتیبانی گسترده‌تر مرورگر نیاز است، به تشخیص ویژگی منشعب نشوید — به‌سادگی اصلاً از popover استفاده نکنید. مشخصه popover="manual" را رها کنید، ارتقای لایه بالایی را با position: fixed و z-index بالا جایگزین کنید، ::backdrop را با عنصر هم‌نیا با سبک یکسان (بااستفاده از همان ویژگی سفارشی --drawer-backdrop) جایگزین کنید، و رؤیت‌پذیری را از کلاس به‌جای showPopover()/hidePopover() تغییر دهید. بقیه مؤلفه (میزان کردن پیمایش، پویانمایی پس‌زمینه پیمایش‌محور، IntersectionObserver، و گرداننده‌های رد کردن) بدون تغییر باقی می‌ماند.