عندما يتنقّل المستخدم في حاوية تنقّل ثنائية الأبعاد، تطبّق المتصفّحات عادةً أسلوبًا إرشاديًا لقفل محور التنقّل (يُعرف أيضًا باسم "railing"). إذا بدأت إيماءة بحركة أكبر بكثير على طول محور واحد مقارنةً بالمحور العمودي، يقفل المتصفّح الانتقال إلى ذلك المحور الأفقي أو العمودي الفردي ويتجاهل الفروق العمودية، غالبًا لبقية الإيماءة.
على الرغم من أنّ قفل المحور يمنع الانحراف الجانبي غير المقصود في تدفقات القراءة أحادية البُعد، إلا أنّه يؤدي إلى حدوث احتكاك على أسطح العرض الشامل ثنائية الأبعاد، مثل الخرائط التفاعلية والصور المكبّرة واللوحات اللانهائية والرسوم البيانية للعُقد وشبكات البيانات الكبيرة. بما أنّ القفل يستمر طوال الإيماءة، فإنّه يؤثر في أي تحريك يتضمّن اتجاهَين، سواء تم تحريك كلا المحورَين في الوقت نفسه (تحريك قطري) أو أحدهما تلو الآخر ضمن إيماءة واحدة (مثل المسارات المنحنية أو الدائرية أو على شكل حرف L). تؤدي السمة scroll-axis-lock في CSS إلى إيقاف هذا الأسلوب الإرشادي مباشرةً في حاوية التنقّل الأصلية، ما يتيح التنقّل ثنائي الأبعاد بدون قيود مع الحفاظ على التنقّل في سلسلة محادثات المُركِّب وفيزياء الزخم وأشرطة التنقّل الأصلية والتنقّل باستخدام لوحة المفاتيح.
التنفيذ
1. إيقاف قفل المحور واحتواء تجاوز حد التمرير في حاوية التمرير الثنائية الأبعاد
طبِّق السمة scroll-axis-lock: none مباشرةً على العنصر الذي ينشئ حاوية الانتقال الثنائية الأبعاد (overflow: auto أو overflow: scroll). على الرغم من أنّ حاوية الانتقال الثنائية الأبعاد يمكنها تعديل قيم دلتا الانتقال على طول المحورَين ضمن القيمة التلقائية auto، تسمح السمة auto بتفعيل الأسلوب الإرشادي الخاص بالمتصفّح عند بدء إيماءة على طول محور واحد بشكل أساسي. يؤدي ضبط الإعداد scroll-axis-lock: none إلى منع تفعيل ميزة "التثبيت" على الشاشات التي تعمل باللمس ولوحات اللمس وعجلات الماوس.
يمكنك إقران scroll-axis-lock: none مع overscroll-behavior: none (أو overscroll-behavior: contain) في حاوية التمرير حتى لا يؤدي الوصول إلى الحافة إلى ربط التمرير بالصفحة الرئيسية أو تشغيل عمليات تنقّل غير مقصودة في المتصفّح بسبب تجاوز حد التمرير (مثل التنقّل الأفقي بالسحب للخلف أو للأمام أو السحب للأسفل لإعادة التحميل).
2. الحفاظ على إمكانية التركيز على لوحة المفاتيح وتسهيل استخدامها
بما أنّ سطح العرض الشامل الثنائي الأبعاد غالبًا ما يفتقر إلى عناصر التحكّم الفرعية القابلة للتركيز (على سبيل المثال، خريطة أو صورة مكبّرة)، تأكَّد من أنّ مستخدمي لوحة المفاتيح يمكنهم التركيز على الحاوية وتحريكها باستخدام مفاتيح الأسهم من خلال إضافة tabindex="0" وrole مناسب وتصنيف يسهّل الوصول ومؤشر :focus-visible مرئي.
مثال على الرمز
<!-- Make the 2D scroll container keyboard-focusable and labeled so keyboard
users can focus the viewport and pan in all directions with Arrow keys. -->
<div class="pan-viewport" tabindex="0" role="region" aria-label="Interactive canvas">
<div class="pan-surface">
<!-- 2D overflowing content (map tiles, zoomed image, canvas nodes, or grid) -->
</div>
</div>
.pan-viewport {
/* Example viewport dimensions; adjust to fit your layout. */
inline-size: 100%;
block-size: 500px;
/* MANDATORY: Establish a scroll container that can overflow on both axes. */
overflow: auto;
/* MANDATORY: Prevent browser scroll axis-locking ("railing") from kicking in
so 2D gestures never get locked to a single axis. */
scroll-axis-lock: none;
/* Contain scrolling and prevent accidental swipe navigations at edges
(use `none` or `contain`). */
overscroll-behavior: none;
}
/* Provide a clear focus indicator when keyboard users focus the viewport. */
.pan-viewport:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
.pan-surface {
/* Example surface dimensions larger than .pan-viewport in both axes. */
inline-size: 3000px;
block-size: 3000px;
}
أفضل الممارسات والقيود
- تطبيق السمة على حاوية الانتقال نفسها، وليس على عنصر تضمين رئيسي: لا يتم توريث السمة
scroll-axis-lock، لذا فإنّ ضبطها على عنصر تضمين ليس له أي تأثير في حاويات الانتقال الفرعية. - احتواء تجاوز حد التمرير ومنع عمليات التنقّل غير المقصودة: أضِف
overscroll-behavior: noneأوoverscroll-behavior: containإلى حاوية التمرير الثنائية الأبعاد كي لا يؤدي العرض الشامل إلى حدود معيّنة إلى ربط الصفحة الرئيسية أو تفعيل التنقّل من خلال التمرير السريع للخلف أو للأمام في المتصفّح وإيماءات السحب للأسفل لإعادة التحميل. - تحافظ اللوحات المتداخلة أحادية البُعد على السكة تلقائيًا: بما أنّ
scroll-axis-lockلا يتم توريثه، فإنّ الشريط الجانبي أو لوحة الفحص المتداخلة داخل.pan-viewportتحتفظ بالسلوك التلقائي لـscroll-axis-lock: auto. - الحفاظ على
autoفي الخلاصات العمودية العادية والمقالات والقوائم أحادية البُعد: يمنع قفل المحور الانحراف العمودي غير المقصود في المحتوى أحادي الاتجاه. - لا تضبط
touch-action: noneبدون شروط: يتحكّمtouch-actionفي ما إذا كان المتصفّح يتعامل مع إيماءات اللمس على الإطلاق (وليس له أي تأثير في لوحة اللمس أو إدخال العجلة)، لذا يؤدي ضبطه إلى إيقاف التمرير باللمس في أداة التركيب الأصلية. ولا تطبِّقها إلا داخل@supports not (scroll-axis-lock: none)عند تنفيذ الإجراء الاحتياطي لمؤشر JavaScript. - التحريك باستخدام
scrollLeftأوscrollTop، وليس باستخدام CSStransform: يؤدي تحريك السطح باستخدامtransformإلى إيقاف العنصر عن العمل كحاوية للتمرير، ما يؤدي إلى تعطيل أشرطة التمرير الأصلية، وتثبيت الحدود، والتمرير باستخدام مفاتيح الأسهم في لوحة المفاتيح، وأحداثscroll، وIntersectionObserver، والصور المتحركة المستندة إلى الانتقال.
استراتيجيات احتياطية
يمكنك الاختيار بين التحسين المتدرِّج والاحتياطي في JavaScript استنادًا إلى متطلبات تطبيقك:
الخيار 1: التحسين التدريجي (بدون JavaScript)
استخدِم التحسين المتدرّج عندما يكون الحفاظ على التمرير باللمس الأصلي في سلسلة عمليات المُركِّب، وزخم الاندفاع بالقصور الذاتي، والتكبير أو التصغير بإصبعين أكثر أهمية من إزالة قفل المحور في المتصفحات غير المتوافقة:
- المتصفّحات المتوافقة: يمنع المتصفّح تفعيل الأسلوب الإرشادي لقفل المحور ("الدرابزين") في حاوية الانتقال الثنائية الأبعاد أثناء إيماءات الانتقال.
- المتصفّحات غير المتوافقة: تتجاهل المتصفّحات التي لا تتعرّف على
scroll-axis-lock: noneالإعلان بأمان وتحافظ على سلوك الانتقال التلقائي ثنائي الأبعادauto، أي الانتقال على كلا المحورَين بشكلٍ أصلي، مع الاستمرار في تقييد الإيماءات التي تبدأ في الغالب على محور واحد.
الخيار 2: الخيار الاحتياطي لـ JavaScript (wheel + pointermove مع scrollLeft/scrollTop)
إذا كان التحريك ثنائي الأبعاد غير المقيّد مطلوبًا في جميع المتصفّحات، استخدِم @supports not (scroll-axis-lock: none) وCSS.supports('scroll-axis-lock', 'none') لاعتراض wheel وإيماءات سحب المؤشر بشكل مشروط.
بما أنّ wheel (e.deltaX وe.deltaY) وpointermove (e.clientX وe.clientY) يعرضان قيم دلتا ثنائية الأبعاد أولية وغير مقفلة قبل أن يطبّق محرّك التمرير في المتصفّح قفل المحور، يمكنك تطبيق قيم دلتا هذه مباشرةً على scrollLeft وscrollTop:
- لماذا نستخدم
scrollLeft/scrollTopبدلاً من CSStransform: يؤدي تعديلscrollLeftوscrollTopإلى إبقاء العنصر كحاوية للتمرير. يحدّد المتصفّح تلقائيًا مواضع الانتقال للأعلى أو للأسفل على[0, scrollWidth - clientWidth]و[0, scrollHeight - clientHeight]، وتظل أشرطة التمرير الأصلية متزامنة، ويستمر الانتقال للأعلى أو للأسفل باستخدام مفاتيح الأسهم في لوحة المفاتيح، ويتم تشغيل أحداثscrollوIntersectionObserverبشكل طبيعي. - المفاضلات: يؤدي اعتراض
wheelباستخدام{ passive: false }إلى نقل الانتقال إلى سلسلة التعليمات الرئيسية، ويؤدي ضبطtouch-action: noneعلى الشاشات التي تعمل باللمس إلى إيقاف زخم الاندفاع بالقصور الذاتي الأصلي بعدpointerup(بالإضافة إلى ميزة التصغير أو التكبير بإصبعين الأصلية).
/* Only disable native touch scrolling when scroll-axis-lock is unsupported
and the JavaScript pointer fallback below is active. */
@supports not (scroll-axis-lock: none) {
.pan-viewport {
touch-action: none;
}
}
// Only attach the main-thread gesture fallback when native support is missing
if (!CSS.supports('scroll-axis-lock', 'none')) {
const viewport = document.querySelector('.pan-viewport');
let lastPointer = null;
// 1. Trackpad and mouse wheel fallback
viewport.addEventListener(
'wheel',
(e) => {
e.preventDefault();
viewport.scrollLeft += e.deltaX;
viewport.scrollTop += e.deltaY;
},
{ passive: false },
);
// 2. Touch and pointer drag fallback
viewport.addEventListener('pointerdown', (e) => {
// Ignore non-primary buttons and clicks on native scrollbars
const rect = viewport.getBoundingClientRect();
if (
!e.isPrimary ||
e.button !== 0 ||
e.clientX - rect.left - viewport.clientLeft >= viewport.clientWidth ||
e.clientY - rect.top - viewport.clientTop >= viewport.clientHeight
) {
return;
}
lastPointer = { x: e.clientX, y: e.clientY };
viewport.setPointerCapture(e.pointerId);
});
viewport.addEventListener('pointermove', (e) => {
if (!lastPointer) return;
viewport.scrollLeft -= e.clientX - lastPointer.x;
viewport.scrollTop -= e.clientY - lastPointer.y;
lastPointer = { x: e.clientX, y: e.clientY };
});
const endPointerPan = () => {
lastPointer = null;
};
viewport.addEventListener('pointerup', endPointerPan);
viewport.addEventListener('pointercancel', endPointerPan);
}