نظرة عامة
لائحة التنقل هي لوحة تنزلق من حافة إطار العرض فوق محتوى الصفحة، ما يؤدي إلى تعتيم كل ما خلفها. يتم فتحها من زر تشغيل ويتم إغلاقها من خلال التمرير سريعًا على اللوحة خارج الشاشة أو النقر على الخلفية المعتمة أو الضغط على مفتاح Escape.
ينفّذ هذا الدليل الدرج على النحو التالي:
- عنصر
popover="manual"تمّت ترقيته إلى الطبقة العليا بحيث تظهر اللوحة و::backdropفوق كل عنصر آخر في الصفحة، بغض النظر عن سياق التكديس. - حاوية تمرير أفقي مع نقطتَي توقّف لمحاذاة مواضع التمرير في CSS، إحداهما لـ "فتح" والأخرى لـ "إغلاق"، بحيث يتعامل المتصفّح مع إيماءة التمرير السريع بشكلٍ أصلي. ويوفّر ذلك تتبُّعًا مجانيًا للزخم والسرعة والمقاطعة، بدون رمز JavaScript لحدث المؤشر.
- صورة متحركة يتم التحكّم بها من خلال الانتقال إلى الأعلى أو الأسفل، وتربط عتامة الخلفية بموضع الانتقال، ما يؤدي إلى تلاشي التعتيم بسلاسة أثناء سحب المستخدم للوحة.
-
IntersectionObserverعلى اللوحة يرصد متى دخلت اللوحة إلى إطار العرض بالكامل أو غادرته بالكامل، ويستخدم هذه اللحظات لتعديل التركيز وaria-expandedوinert.
يُفضَّل هذا الأسلوب على استخدام تأثيرات الحركة transform المستندة إلى JavaScript لأنّ آلية الانتقال تمنح المستخدم تحكّمًا مباشرًا في موضع اللوحة (يتحكّم المستخدم في اللوحة بإصبعه، وليس من خلال تأثيرات الحركة البينية)، كما أنّها تتطابق بشكل أكبر مع أنماط التفاعل التي اعتاد عليها المستخدمون في التطبيقات الأصلية لنظام الجهاز الجوّال.
التنفيذ
1. Markup
الدرج هو نافذة منبثقة واحدة تحتوي على شريط تمرير أفقي، والذي يحتوي على "الورقة" المرئية. يظهر زر التشغيل في محتوى الصفحة.
<!-- 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>
2. الأنماط
إعادة ضبط النافذة المنبثقة وملء إطار العرض
يجب أن يغطي الإطار المنبثق إطار العرض بالكامل بحيث يؤدي ::backdrop إلى تعتيم الصفحة بأكملها ويمتد سطح التمرير من الحافة إلى الحافة. تتداخل أنماط النوافذ المنبثقة التلقائية لوكيل المستخدم (المتوسطة، ذات الحجم التلقائي، ذات الحدود) ويجب إعادة ضبطها.
.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);
}
إنشاء سطح التمرير السريع باستخدام ميزة "محاذاة مواضع التمرير"
شريط التمرير هو شبكة أفقية أعرض من إطار العرض: يحتوي العمود 1 على الورقة (العرض --drawer-width)، والعمود 2 هو فاصل فارغ لعنصر زائف بعرض إطار العرض. يؤدي الانتقال بين العمودين إلى فتح الدرج وإغلاقه.
.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 من 1 (مفتوح) إلى 0 (مغلق) على مستوى نطاق شريط التمرير، وبالتالي تتلاشى الخلفية وتظهر بشكل متزامن تمامًا مع السحب.
/* 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 }
}
}
3- فتح الدرج وإغلاقه
يتألف الفتح من خطوتين: ترقية النافذة المنبثقة إلى الطبقة العليا، ثم الانتقال إلى الورقة لعرضها. يتم الإغلاق في خطوة واحدة: انتقِل إلى الفاصل، ويخفي المراقب (الخطوة 4) النافذة المنبثقة بعد إزالة الورقة بالكامل من الشاشة.
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'});
}
4. رصد حالة الفتح والإغلاق
استخدِم 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);
5- ربط معالِجات التفعيل والإغلاق
// 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 هي تحسينات تدريجية مع إجراءات احتياطية بسيطة يمكن تنفيذها بسهولة إذا كان مطلوبًا توفير دعم واسع النطاق للمتصفّح.
تلاشي الخلفية كإجراء احتياطي (في حال عدم توفّر ميزة animation-timeline):
يمكنك رصد ذلك باستخدام CSS.supports('animation-timeline: scroll()') وكتابة --drawer-backdrop من متتبِّع الأحداث scroll إذا لم يكن متوافقًا. تضمن مجموعة CSS @supports في الخطوة 2 عدم تطبيق الإطارات الرئيسية في المتصفّحات غير المتوافقة، وبالتالي تكون قيمة JavaScript هي القيمة الوحيدة التي يتم تطبيقها.
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 بين الإطارات الرئيسية، وبدون التسجيل، سيتم التعامل مع السمة كسلسلة وستنتقل بين 0 و1 بدون تلاشي. إذا كان الإجراء الاحتياطي للحركة المستندة إلى الانتقال أعلاه مفعّلاً، يكتب رمز JavaScript سلسلة رقمية جديدة في --drawer-backdrop في كل إطار انتقال ولا يتم الاستكمال مطلقًا، لذا لا حاجة إلى إجراء احتياطي منفصل لـ @property لأنّ جميع المتصفّحات التي تتيح الحركات المستندة إلى الانتقال تتيح أيضًا استخدام @property.
الاحتياطي لواجهة Popover API (لا تتوفّر السمة popover):
بما أنّ هذا المكوّن يستخدم popover="manual" وينفّذ الإغلاق بالكامل من JavaScript، فهو لا يعتمد على السلوكيات المحدّدة في واجهة برمجة التطبيقات popover API، مثل الإغلاق البسيط أو السمة popovertarget أو معالجة مفتاح Escape المُدار من الطبقة العليا أو إدارة التركيز. ميزات popover الوحيدة التي يستخدمها فعليًا هي الترويج للطبقة العليا (من خلال showPopover()) والعنصر الزائف ::backdrop.
إذا كنت بحاجة إلى دعم أوسع للمتصفّح، لا تستخدم ميزة رصد الميزات، بل لا تستخدم ميزة popover على الإطلاق. يمكنك إزالة السمة popover="manual"، واستبدال العنصر العلوي في الطبقة العليا بـ position: fixed وz-index عالية، واستبدال ::backdrop بعنصر شقيق منسَّق بشكل مماثل (باستخدام السمة المخصّصة --drawer-backdrop نفسها)، وتبديل مستوى العرض من فئة بدلاً من showPopover()/hidePopover(). يبقى باقي المكوّن (محاذاة مواضع التمرير، والرسوم المتحركة للخلفية المستندة إلى التمرير، وIntersectionObserver، ومعالِجات الإغلاق) بدون تغيير.