UIDex

الدرج الجانبي

Drawer / Side Panel

role="dialog" / inset-inline-start

اسمه كماندرور، سايد بانل، اللوحة الجانبية المنزلقة، قائمة جانبية منزلقة، درج التنقّل، ورقة جانبية، side panel، side sheet، navigation drawer، slide-out panel، off-canvas menu، flyout panel

الدرج الجانبي سطح مؤقّت ينزلق من إحدى حواف المنطقة المرئية، يلتصق بها ويمتدّ على طولها، ثم ينسحب ويختفي حين يُغلق. والصفة التي تعرّفه هي اقتران الارتكاز على حافّة ببقاء السياق خلفه مرئياً: المستخدم ما زال يرى الصفحة التي جاء منها، ولهذا صار المكان الطبيعي للتنقّل والفلاتر ولوحات التفاصيل. وهو ليس نافذة حوارية (modal)، فالنافذة تتوسّط المنطقة المرئية ولا تنتمي إلى حافّة، وهي قائمة أصلاً لتنتزع منك قراراً قبل أن تكمل. وليس شريطاً جانبياً (sidebar)، لأن الشريط جزء دائم من تخطيط الصفحة يقاسمها المساحة ويزيح المحتوى بدل أن يعلوه، ولا يُغلق بـ Escape ولا بالنقر خارجه لأنه لا يُغلق أصلاً. ولا يُخلط بالورقة السفلية (bottom sheet): تلك نظيره على الموبايل، مرتكزة على الحافّة السفلية وتتحرّك على المحور الكتلي لا المحور المضمّن. وانتبه أن الحَجْب ليس جزءاً من التعريف: الدرج قد يكون حاجباً بطبقة تعتيم وحصر تركيز وقفل تمرير، وقد يكون غير حاجب فيبقى ما خلفه قابلاً للنقر وللوصول بـ Tab، فالحجب محور مستقلّ تماماً عن الارتكاز على الحافّة. والاختبار العملي: إن لم يستطع المستخدم أن يغلقه ويكمل ما كان فيه، فما بنيتَه نافذة حوارية في هيئة درج.

لو قلت عليه…

«القايمة اللي بتطلع من الجنب لما تدوس على التلات شرط»«اللوح اللي بيزحلق من على الطرف ويعتّم الصفحة شويّة»«الفلاتر اللي بتفتح على الجنب من غير ما الصفحة تتغيّر»«الحتّة الجانبية اللي بتقفلها بالسحب بصباعك»«المنيو الجانبي المتزحلق بتاع الموبايل»

العيّنة الحيّة

تفاعل مع الديمو. كل الأجزاء حقيقية ومرقّمة.

المشاريع
inert
RTLالحافّة المرتكِزة نفسها تنقلب، والسحب معها

تشريح العنصر: كل جزء واسمه

مرّر على أي سطر ليتحدّد مكانه في الديمو فوق.

برومبت البناء

ابنِ درجاً جانبياً (drawer) للتنقّل يفتح من الحافّة المنطقية للبداية. اجعله عنصراً بـ role="dialog" و aria-labelledby يشير إلى عنوانه المرئي، ولا تضف aria-modal="true" إلا في الوضع الحاجب. ثبّته بالخصائص المنطقية وحدها: inset-inline-start: 0 و inset-block: 0 و inline-size: min(320px, 88vw) و border-inline-end للفاصل، وممنوع أن تحتوي أي قاعدة على left أو right أو width أو border-right. عرّف ‎--dir: 1 على الجذر و ‎--dir: -1 داخل [dir="rtl"]، ونفّذ الدخول والخروج بـ transform: translateX(calc(-100% * var(--dir))) لا بتحريك inset. الزرّ المشغِّل يحمل aria-haspopup="dialog" و aria-expanded يتبع الحالة لحظة بلحظة، والتركيز يعود إليه هو عند الإغلاق. في الوضع الحاجب: طبقة حاجبة بـ inset: 0 تُغلق بالنقر، وحصر تركيز حقيقي عبر inert على أشقّاء الدرج لا عبر aria-modal وحدها، وقفل تمرير للصفحة مع scrollbar-gutter: stable على الجذر و overscroll-behavior: contain على اللوح. في الوضع غير الحاجب: بلا طبقة ولا حصر تركيز ولا قفل تمرير، وما خلفه يبقى قابلاً للنقر وللوصول بـ Tab. أتِح ثلاث طرق للإغلاق: زرّ × عند inset-inline-end، ومفتاح Escape، وسحب اللوح نحو حافّته. واشتقّ إشارة السحب وقت الحدث من getComputedStyle(el).direction لأن clientX فيزيائي ولا ينعكس في RTL. واحترم prefers-reduced-motion بإلغاء الانزلاق والاكتفاء بتلاشٍ.

سلوكه في الاتجاه من اليمين لليسار

الجزء ده حصري عندنا.

الحافّة المرتكِزة نفسها تنقلب

ينعكس

هذا هو الانعكاس الذي ينساه الجميع: ليس محتوى الدرج ما ينقلب، بل الحافّة التي يرتكز عليها ويخرج منها. فدرج التنقّل الذي يخرج من يسار الشاشة في الإنجليزية يجب أن يخرج من يمينها في العربية، لأنه مربوط ببداية القراءة لا باليسار الفيزيائي. اكتبه inset-inline-start: 0 مع inset-block: 0 و inline-size صريح، واحذف left و width تماماً، وحوّل الفاصل بين اللوح والمحتوى إلى border-inline-end بدل border-right. والاستثناء المشروع الوحيد درج مرتبط بمعنى ثابت لا بالقراءة، كلوحة تفاصيل تفتح دائماً عند نهاية جدول، وحتى هذا يُكتب inset-inline-end لا right.

حركة الانزلاق تحتاج متغيّر اتجاه

ينعكس

لا يوجد نظير منطقي لـ translateX: دالة الإزاحة تعمل على المحور الفيزيائي X مهما كان اتجاه الصفحة، ولا شيء اسمه translate-inline في المواصفة. لذا translateX(-100%) الثابت يجعل الدرج في العربية يبدأ خارج الشاشة من الجهة المقابلة لحافّته ويعبر عرضها كلّه قبل أن يستقرّ. عرّف ‎--dir: 1 على الجذر و ‎--dir: -1 داخل [dir="rtl"]، ثم اكتب transform: translateX(calc(-100% * var(--dir))). البديل تحريك inset-inline-start من ‎-320px إلى صفر وهو ينعكس مجّاناً، لكنه يجبر المتصفّح على إعادة التخطيط في كل إطار بينما transform يُنفَّذ على المُركِّب.

اتجاه السحب للإغلاق ينعكس

ينعكس

إحداثيات المؤشّر واللمس فيزيائية دائماً: clientX يزداد نحو يمين الشاشة في العربية والإنجليزية على السواء، والمتصفّح لا يعكسها لك في RTL. لذا شرط مثل if (deltaX < -60) close() خطأ صريح لا مسألة ذوق: في العربية يجلس الدرج المرتكز على البداية عند يمين الشاشة، فالسحبة التي تُبعده نحو حافّته تتّجه يميناً وتُعطي deltaX موجباً لا يحقّق الشرط أبداً فيرتدّ اللوح إلى مكانه، بينما الحركة الوحيدة التي تحقّقه هي السحب يساراً بعيداً عن الحافّة نحو داخل الصفحة، فينغلق الدرج بجرّه فوق المحتوى: عكس ما تفعله يد المستخدم تماماً. اقرأ الاتجاه وقت الحدث عبر const sign = getComputedStyle(panel).direction === "rtl" ? -1 : 1، واضرب فيه الفرق قبل أي مقارنة. ونفس الإصلاح يلزم حدّ السرعة velocityX ومفتاحي ArrowLeft و ArrowRight إن أضفتهما.

زرّ الإغلاق والظلّ

ينعكس

الاثنان ينتقلان معاً إلى الجهة المقابلة. زرّ × يقف عند النهاية المنطقية لرأس اللوح: اجعله آخر ابن في صفّ flex مع justify-content: space-between فينضبط وحده، أو ثبّته بـ inset-inline-end بدل right. أمّا الظلّ فمصيدة أهدأ: box-shadow ليس له صيغة منطقية في CSS إطلاقاً، وقيمة مثل 8px 0 24px تُلقي الظلّ بعد الانعكاس خارج حافّة الشاشة حيث يقصّه المتصفّح، فيفقد اللوح ارتفاعه فوق المحتوى الذي يُفترض أنه يعلوه. اكتب الإزاحة الأفقية calc(8px * var(--dir))، أو صفّرها واكتفِ بالتمويه وحده فهو متماثل بطبعه. والرمز × متماثل حول محوره الرأسي فلا يُقلب، بينما سهم الطيّ « أيقونة اتجاهية تنعكس معه.

ما لا ينعكس: المحور الكتلي والحجب

لا ينعكس

اتجاه الصفحة يحكم المحور المضمّن وحده. فالدرج المرتكز على حافّة كتلية، كالحافّة السفلية، لا ينتقل بين اللغتين أبداً: inset-block-end: 0 يبقى الأسفل نفسه، ومحور سحبه deltaY يبقى بنفس الإشارة، فلا تضف إليه متغيّر اتجاه من الأساس. والطبقة الحاجبة محايدة كذلك: inset: 0 يغطّي كل شيء وليس فيه ما ينقلب. وقفل التمرير خلف الدرج محايد هو الآخر، فـ overflow: hidden و overscroll-behavior: contain لا علاقة لهما بالاتجاه، وفيه شيء واحد ينقلب: موضع شريط التمرير المختفي يقع عند الحافّة المنطقية النهائية، أي على اليسار في صفحة dir="rtl"، فعوّضه بـ scrollbar-gutter: stable لا بـ padding-right ثابت.

أسماؤه في الكود

كل سطر هو كلمة مكتبة واحدة عن نفس الشيء. خد السطر اللي بيكلّم مشروعك.

ARIArole="dialog" + aria-modal="true"الحجب قرار منفصل عن الحافّة؛ الدرج غير الحاجب يُسقط aria-modal تماماً.
HTML<dialog>.showModal()يمنحك ::backdrop و Escape والطبقة العليا مجّاناً، لكن الوضع الافتراضي متوسّط فتبقى الحافّة عليك.
shadcn/ui<SheetContent side="left">مبني على Radix Dialog، و side يسكن على SheetContent لا على Sheet، وقيمه فيزيائية بحتة فتبديلها في RTL مسؤوليتك.
Material UI<Drawer anchor="left" variant="temporary">variant="permanent" يحوّل نفس المكوّن إلى شريط جانبي: هي الخاصية التي تقرّر أي عنصر بنيتَ فعلاً.
Ant Design<Drawer placement="left" mask={false} />mask={false} يجعله غير حاجب: خاصية واحدة تقرّر هل يبقى ما خلفه صالحاً للاستعمال.
Jetpack ComposeModalNavigationDrawer / DismissibleNavigationDrawer / PermanentNavigationDrawerثلاثة أسماء لشكل واحد: حاجب، وقابل للطيّ، ودائم؛ والأخير شريط جانبي يحمل اسم درج.
CSSinset-inline-startالخاصية التي تجعل الحافّة المرتكِزة تتبع اتجاه القراءة من تلقاء نفسها.

شوف كمان

آخر تحديث · 2026-08-19