إدارة تركيز لوحة المفاتيح في Next.js App Router: تغيير المسار الذي يفقد التركيز، وinert، وعنصر dialog الأصلي
جدول المحتويات
عن الكاتب

Ahmed Mahmoud
كاتب ومطوّر
مهندس برمجيات شغوف بتطوير الويب وتصميم تجربة المستخدم.
مؤسّس ديفيا · eng-ahmed.com ↗أبرز النقاط
- يحقن Next.js عنصر «معلن المسار» في كل تنقّل من جانب العميل: منطقة aria-live مخفية بصريًا تقرأ قيمة document.title الجديدة، وتعود إلى نص عنوان h1 عند غيابها. هو يتكفّل بالإعلان فقط ولا يتكفّل بالتركيز.
- التنقّل من جانب العميل في Next.js App Router لا يعيد ضبط تركيز لوحة المفاتيح. عندما يُزال الرابط الذي جرى تفعيله من الصفحة، يعود document.activeElement إلى عنصر body، فتبدأ ضغطة Tab التالية من أعلى الصفحة بدل المحتوى الجديد.
- الحل مكوّن عميل يراقب usePathname() من next/navigation ويستدعي .focus({ preventScroll: true }) على حاوية تحمل tabIndex={-1}. الخاصية tabIndex={-1} تجعل عنصرًا غير تفاعلي قابلًا للتركيز برمجيًا دون إدخاله في ترتيب Tab.
- الخاصية inert خاصية HTML عامة تزيل شجرة عناصر كاملة من ترتيب Tab ومن شجرة إمكانية الوصول ومن استقبال النقر بتصريح واحد. أما aria-hidden="true" فتزيلها من شجرة إمكانية الوصول فقط وتترك كل زر بداخلها قابلًا للوصول عبر Tab.
- عنصر dialog الأصلي يحبس التركيز ويُعرض في الطبقة العليا للمتصفح فقط عند فتحه برمجيًا عبر showModal(). أما كتابته في JSX بخاصية open فتعطي حوارًا غير نمطي بلا حبس تركيز وبلا خلفية.
اكتشفنا هذا بالطريقة التي يكتشف بها معظم الفرق أخطاء إمكانية الوصول: لا بأداة فحص، بل بفصل الفأرة ليوم كامل. عند التنقّل في لوحة التحكم بلوحة المفاتيح وحدها، كان كل رابط يترك المستخدم تائهًا — الصفحة تغيّرت، وضغطة Tab تعيده إلى شعار الموقع. ومنذ دخول قانون إمكانية الوصول الأوروبي حيّز التطبيق في 28 يونيو 2025، لم يعد هذا النوع من الأخطاء مسألة حِرَفية فقط في كثير من المنتجات التي يشحنها عملاؤنا.
ماذا يفعل Next.js App Router بالفعل لقارئات الشاشة عند التنقّل؟
يُعلن عن عنوان الصفحة الجديد، وهذا كل ما يفعله. يعرض Next.js عنصر معلن المسار داخل DOM — منطقة مخفية بصريًا تحمل aria-live — ويكتب فيها اسم الصفحة الجديد عند كل تنقّل من جانب العميل. يقرأ document.title أولًا ويعود إلى نص h1 عند غيابه. لذلك يسمع مستخدم قارئ الشاشة أن شيئًا ما تغيّر.
تبقى ثغرتان على كود التطبيق أن يسدّهما. الأولى أن جودة الإعلان مرهونة بجودة العنوان: عنوان يُحسم متأخرًا عبر generateMetadata داخل قطعة تُبثّ تدريجيًا قد يصل بعد إطلاق الإعلان، فيُقرأ اسم الصفحة السابقة. العنوان المميّز المحسوم استاتيكيًا لكل مسار هو النسخة الموثوقة. أما الثغرة الثانية فأكبر، وهي التركيز.
أين يجب أن يذهب تركيز لوحة المفاتيح بعد تغيير المسار؟
التركيز مكانه أعلى المحتوى الرئيسي الجديد. التنقّل الكامل عبر المتصفح يفعل ذلك مجانًا بإعادة التركيز إلى بداية المستند. أما التنقّل من جانب العميل فلا يفعله، لأن المتصفح لا يرى أن تنقّلًا حدث — React استبدل بعض العُقد فقط. والرابط الذي فعّله المستخدم أحد تلك العُقد، فيسقط التركيز إلى عنصر body ويبدأ التنقّل التسلسلي من أعلى الصفحة، فيمرّ المستخدم على الترويسة كاملة بعد كل نقرة.
مكوّن RouteFocus لدينا خمسة عشر سطرًا: يقرأ pathname من usePathname()، وداخل useEffect مرتبط بـ pathname يستدعي ref.current?.focus({ preventScroll: true }) على عنصر div يحمل tabIndex={-1}، مع حارس يتخطّى التشغيل الأول.
ثلاث تفاصيل في هذه الأسطر تستحق مكانها. تخطّي أول تصيير مهم لأن المتصفح يكون قد وضع التركيز أصلًا عند بداية المستند عند التحميل، وسحبه مجددًا مجرّد ضوضاء. وpreventScroll: true مهمّة لأن Next.js يستعيد موضع التمرير عبر التنقّلات، واستدعاء .focus() بلا حماية يُمرّر الحاوية إلى العرض ويصطدم بتلك الاستعادة. أما tabIndex={-1} فهي ما يجعل عنصر div عاديًا قابلًا للتركيز برمجيًا مع بقائه خارج تسلسل Tab؛ وبدونها لا يفعل .focus() شيئًا بصمت.
نغلّف المنطقة الرئيسية في التخطيط لا الصفحة، حتى يظل هدف التركيز موجودًا أثناء عرض حدّ loading.tsx: رابط تخطٍّ أولًا، ثم شريط التنقّل، ثم RouteFocus يغلّف عنصر main.
رابط التخطّي يعالج النصف الآخر من المشكلة نفسها، وله شرطان: أن يكون أول عنصر قابل للتركيز في DOM، وأن يصبح مرئيًا عند تركيزه. ورابط تخطٍّ يشير إلى #main لا يعمل إلا إذا كان الهدف قادرًا على استقبال التركيز — وهي قاعدة tabIndex={-1} نفسها.
كيف نحبس التركيز داخل نافذة حوار دون مكتبة focus-trap؟
نستخدم عنصر dialog الأصلي ونفتحه بـ showModal(). تضع HTMLDialogElement.showModal() الحوار في الطبقة العليا للمتصفح، وتعرض العنصر الزائف ::backdrop، وتجعل كل ما خلفه خاملًا، وتغلقه عند Escape عبر إطلاق حدث cancel. وهذه معظم الميزات التي كُتبت حزم focus-trap لإعادة بنائها بجافاسكربت.
الحبس يأتي من استدعاء الدالة لا من الوسم. فالحوار المكتوب تصريحيًا بخاصية open حوار غير نمطي: بلا طبقة عليا، وبلا خلفية، وبلا حبس تركيز. في React يعني ذلك قيادته من داخل effect: if (open && !el.open) el.showModal(); ثم if (!open && el.open) el.close();
يبقى سلوكان يحتاجان كودًا. التركيز الأولي يذهب إلى أول عنصر قابل للتركيز بالداخل، وهو غالبًا زر الإغلاق، لذا نضع autoFocus على العنصر الذي نريده فعلًا. والتركيز لا يُستعاد عند الإغلاق: نلتقط document.activeElement قبل استدعاء showModal() ثم نستدعي .focus() عليه بعد close().
أي أسلوب حوار يحبس التركيز؟
- dialog مفتوح بـ showModal(): يحبس التركيز داخل المتصفح، وEscape يطلق cancel، ويُعرض في الطبقة العليا مع ::backdrop.
- dialog بخاصية open في JSX: بلا حبس تركيز، وبلا معالجة Escape، وبلا طبقة عليا.
- خاصية popover: لا تحبس التركيز بحكم التصميم، وEscape والإغلاق بالنقر الخارجي يعملان، وتُعرض في الطبقة العليا.
- عنصر div بدور role="dialog": لا يحبس إلا ما تكتبه بنفسك، ولا يُغلق إلا بمعالجات تكتبها، ويعيش في z-index لا في الطبقة العليا.
خاصية popover هي الخيار الصحيح للقوائم والتلميحات واللوحات غير الحاجبة، تحديدًا لأنها لا تحبس التركيز. واستخدامها لبناء حوار تأكيد هو الخطأ الأكثر شيوعًا الآن، لأن الميزتين وصلتا في وقت متقارب بما يكفي لتبدوا متبادلتين.
متى نستخدم inert بدل aria-hidden؟
نستخدم inert حين يجب ألا يصل المستخدم إلى المحتوى إطلاقًا، ونستخدم aria-hidden فقط حين يكون المحتوى مرئيًا لكنه زائد عن الحاجة للتقنيات المساعِدة. وهما ليستا بديلتين لبعضهما. فـ aria-hidden="true" تخفي الشجرة من شجرة إمكانية الوصول وتترك كل زر بداخلها قابلًا للوصول عبر Tab، وهي أسوأ حالة ممكنة: يهبط التركيز على عنصر تحكّم لن يصفه قارئ الشاشة.
أما inert فخاصية HTML عامة واحدة تزيل الشجرة من ترتيب Tab ومن شجرة إمكانية الوصول ومن استقبال النقر دفعة واحدة. وتتعامل React 19 معها كخاصية منطقية حقيقية، فتُترجم inert={isDrawerOpen} كما ينبغي؛ بينما كانت React 18 تتوقع نصًا وتحذّر من القيمة المنطقية.
- inert: المحتوى الخلفي خلف درج مخصّص، وشرائح الكاروسيل خارج الشاشة، وقسم نموذج غير متاح مؤقتًا.
- aria-hidden: الأيقونات الزخرفية بجوار نص مرئي، والمحتوى المكرّر لأغراض التخطيط فقط.
- display: none: تزيل العنصر من التخطيط ومن الشجرتين معًا، وهي الخيار الافتراضي الصحيح لمحتوى غير موجود فعليًا.
ومع showModal() لا نحتاج أيًّا من الثلاثة، لأن المتصفح نفسه يطبّق الخمول على كل ما هو خارج الطبقة العليا.
كيف نُعلن التحديثات غير المتزامنة مثل نتائج البحث وأخطاء النماذج؟
نضع منطقة aria-live في DOM قبل وصول المحتوى، ثم نغيّر نصّها. فقارئ الشاشة يعلن التغييرات داخل منطقة كان يراقبها بالفعل، لذا فإن تركيب عنصر aria-live في اللحظة نفسها التي تصل فيها النتائج لا يُعلن شيئًا غالبًا. نُبقي فقرة مخفية بصريًا تحمل aria-live="polite" مركّبة دائمًا، ونبدّل نصّها فقط بين «جارٍ البحث» وعدد النتائج.
نختار درجة الإلحاح بوعي. الدور role="status" يقابل aria-live="polite" وينتظر توقّفًا في النطق، وهو الصحيح لعدد النتائج ومؤشرات الحفظ والإشعارات. أما role="alert" فيقابل aria-live="assertive" ويقاطع فورًا، وهو صحيح فقط لأخطاء التحقق والأعطال التي يجب على المستخدم معالجتها الآن.
ولأخطاء النماذج نتجنّب منطقة aria-live كلما أمكن. فنقل التركيز إلى أول حقل غير صالح يُعلن الرسالة عبر aria-describedby الخاص بالحقل نفسه، ويضع المؤشر حيث يجب أن يحدث الإصلاح. منطقة الإعلان تخبر المستخدم أن هناك خطأ؛ والتركيز يخبره أين هو.
كيف نمنع تراجع إمكانية الوصول بلوحة المفاتيح في CI؟
محرّكات القواعد الآلية تلتقط أخطاء الوسوم ولا تلتقط أخطاء التركيز إطلاقًا. تجد @axe-core/playwright التسميات الناقصة وفشل التباين وسمات ARIA غير الصالحة، لكنها لا تستطيع إخبارك أن التركيز ذهب إلى عنصر body بعد تغيير المسار، لأن تلك الحالة HTML صالحة تمامًا. التركيز يحتاج تأكيدات صريحة.
نحتفظ باختبارَي Playwright: الأول ينقر رابط تنقّل ويؤكّد expect(page.locator('main')).toBeFocused()، والثاني يفتح حوارًا ويضغط Escape ويؤكّد أن زر الفتح صار مركَّزًا مجددًا. اختباران، واحد لكل صنف من الأخطاء، يغطّيان الخطأين اللذين يبلّغ عنهما مستخدمو لوحة المفاتيح فعلًا. ويعمل فحص axe على القوالب الرئيسية ضمن المجموعة نفسها للنصف الخاص بالوسوم.
الأسئلة الشائعة
س: هل ينقل Next.js التركيز تلقائيًا عند تغيير المسار؟
ج: لا. يحقن App Router معلن مسار يقرأ عنوان المستند الجديد داخل منطقة aria-live، لكنه لا يغيّر document.activeElement. نقل التركيز مسؤولية كود التطبيق.
س: هل نضع tabIndex={-1} على h1 أم على عنصر غلاف؟
ج: الغلاف حول main هدف أمتن، لأنه يصمد في الصفحات التي لا تحتوي h1 أو التي تبثّه متأخرًا. كلاهما يُعلَن، لكن الغلاف لا يمكن أن يغيب.
س: هل شحن inert آمن في 2026؟
ج: نعم. inert مدعومة في الإصدارات الحالية من Chrome وEdge وSafari وFirefox منذ عدة دورات إصدار. وتقبلها React 19 كخاصية منطقية، بينما كانت React 18 تتطلّب قيمة نصية.
س: هل ما زلنا نحتاج مكتبة focus-trap؟
ج: ليس لنوافذ الحوار النمطية، لأن dialog المفتوح بـ showModal() يحبس التركيز داخل المتصفح. تبقى المكتبة مفيدة لأنماط غير حوارية مثل خطوة معالج مضمّنة يجب أن تحتوي التركيز دون دخول الطبقة العليا.
س: هل تمنع aria-hidden العنصر من قابلية التركيز؟
ج: لا، وهذا الجمع خطأ بحد ذاته. تزيل aria-hidden="true" العنصر من شجرة إمكانية الوصول وتبقيه في ترتيب Tab، فيستطيع مستخدم لوحة المفاتيح تركيز عنصر تحكّم لن يصفه أي قارئ شاشة. استخدم inert بدلًا منها.
لم يتطلّب أي من هذا اعتمادية خارجية. مكوّن عميل من خمسة عشر سطرًا، وخاصية HTML واحدة، وعنصر أصلي واحد، واختباران في Playwright غطّت كل خطأ لوحة مفاتيح استطعنا إيجاده في يوم واحد بلا فأرة. يمنحك App Router الإعلان مجانًا، أما التركيز فما زال عليك أنت أن تنقله.
قراءات إضافية
هندسة الواجهات
الوضع الداكن دون وميض في Next.js 16: كيف نستخدم سكربت سمة حاجبًا وcolor-scheme وزر تبديل آمنًا مع الـ Hydration
وميض الوضع الداكن عند إعادة تحميل الصفحة مشكلة توقيت: الخادم لا يستطيع قراءة localStorage. نشرح سكربت السمة الحاجب الذي نستخدمه في Next.js 16، ولماذا لا نضع السمة في cookie حفاظًا على الرسم الثابت، وكيف نبني زر تبديل يمر بالـ hydration دون أي عدم تطابق.
هندسة الواجهات
صور Open Graph الديناميكية في Next.js 16: كيف نبني opengraph-image.tsx ونتحقق من إصدار Satori الذي تضمّنه next/og ونحافظ على صحة معاينات الروابط
واجهت بطاقات المشاركة المولّدة لدينا ثلاث مفاجآت: خط WOFF2 لم يستطع المحرّك قراءته، وملف README لـ Satori يصف محرّكًا أحدث من المضمّن في Next.js، وصفحة ظلّت تعرض صورة الـ layout. هذه ملاحظاتنا الميدانية حول opengraph-image.tsx في Next.js 16 وأولوية البيانات الوصفية وفحص og:image قبل مشاركة أي رابط.