الوحدة 3 · لوحة المفاتيح والتركيز وARIA
إدارة التركيز: نوافذ الحوار والتطبيقات أحادية الصفحة
20 دقراءة 4 دبيئة تجريب الكود
الدرس في لمحة
انقل تركيز لوحة المفاتيح عن قصد حين يتغيّر المحتوى دون تحميل صفحة جديدة، باستخدام عنصر dialog الأصلي وخاصية inert وإدارة التركيز عند تغيّر المسار.
لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.
في موقع الويب التقليدي، يتولّى المتصفّح أمر التركيز نيابةً عنك. كل نقرة على رابط تحمّل صفحة جديدة، فيعود التركيز إلى الأعلى، ويُعلن قارئ الشاشة العنوان الجديد. لكن Ticketly تطبيق أحادي الصفحة (single-page app) فيه نوافذ منبثقة وأدراج وتوجيه في جهة العميل، فلا يحدث شيء من ذلك. حين يغيّر كودك الشاشة، يقرّر كودك أين يذهب التركيز. وإن لم يقرّر، يبقى التركيز حيث كان، وكثيرًا ما يكون ذلك على شيء صار مخفيًا أو مغطّى أو محذوفًا.
النافذة التي لا تحبس أحدًا
يفتح زر “Book now” في Ticketly نافذة حجز منبثقة: div عليه طبقة معتمة. راقب ما يحدث بلوحة المفاتيح. تظهر النافذة، لكن التركيز ما زال على “Book now” خلفها. ينتقل Tab إلى رابط المهرجان التالي في الصفحة التي تحتها. ولا يقول قارئ شاشة سمير شيئًا عن أي نافذة؛ فبالنسبة له لم يحدث شيء.
نافذة الحوار المشروطة (modal dialog) الصحيحة تحتاج إلى خمسة سلوكيات:
- ينتقل التركيز إلى داخل النافذة حين تُفتح.
- يبقى Tab وShift+Tab داخلها ما دامت مفتوحة.
- تُخفى الصفحة التي خلفها عن التقنيات المساعدة.
- يُغلقها مفتاح Escape.
- يعود التركيز إلى عنصر التحكّم الذي فتحها.
لسنوات كان هذا يتطلّب مكتبة لحبس التركيز وخمسين سطرًا من الكود. أما اليوم فعنصر <dialog> الأصلي المفتوح بـ showModal() يفعل السلوكيات الخمسة كلها.
<button type="button" id="book">Book now</button>
<dialog id="booking" aria-labelledby="booking-title">
<h2 id="booking-title">Book Friday pass</h2>
<label for="qty">Quantity</label>
<input id="qty" type="number" min="1" max="6" value="1">
<button type="button" id="confirm">Confirm</button>
<button type="button" id="close">Close</button>
</dialog>const dialog = document.querySelector('#booking');
document.querySelector('#book').addEventListener('click', () => dialog.showModal());
document.querySelector('#close').addEventListener('click', () => dialog.close());يضع showModal() النافذة في الطبقة العليا (top layer) فوق كل شيء، ويجعل بقية المستند inert (خاملًا): غير قابل للتركيز ولا للنقر وغائبًا عن شجرة إمكانية الوصول. يذهب التركيز إلى العنصر الذي عليه autofocus إن وُجد، وإلا فإلى أول عنصر قابل للتركيز داخلها (هنا حقل الكمية)، وإلا فإلى النافذة نفسها. ويُغلقها Escape. وحين تُغلق، يعيد المتصفّح التركيز إلى العنصر الذي كان عليه قبلها، أي زر “Book now”. وتنسّق الـ pseudo-element ::backdrop الطبقة المعتمة.
أعطِ كل نافذة حوار اسمًا بـ aria-labelledby تشير إلى عنوانها. حين تُفتح، يقرأ NVDA "Book Friday pass, dialog, Quantity, spin button, 1"، وهذا يخبر سمير بالضبط أين هو. وإن لم يكن أول حقل في النافذة أفضل نقطة بداية، كنافذة شروط طويلة مثلًا، فضع autofocus على العنصر الذي يجب أن يتلقّى التركيز، مثل زر الإغلاق فيها، أو عنوانها بعد جعله قابلًا للتركيز بـ tabindex="-1" لتبدأ القراءة من الأعلى.
inert لكل ما سوى ذلك
ليست كل لوحة مخفية نافذة حوار. تنزلق قائمة الهاتف في Ticketly خارج الشاشة حين تُغلق، لكن روابطها ما زالت في الـ DOM وما زالت قابلة للتركيز، فيمرّ مستخدمو لوحة المفاتيح على ثمانية روابط غير مرئية. خاصية inert تُطفئ شجرة فرعية كاملة: لا تركيز، ولا نقر، ولا شجرة إمكانية وصول.
<nav id="mobile-menu" class="drawer" inert>…</nav>function setMenuOpen(open) {
menu.inert = !open;
menu.classList.toggle('drawer--open', open);
toggle.setAttribute('aria-expanded', String(open));
}خاصية inert مدعومة في كل المتصفّحات الرئيسية منذ 2023. استخدمها للأدراج المغلقة، وشرائح الـ carousel الواقعة خارج الشاشة، والمحتوى الذي خلف طبقة مخصّصة لا تستطيع تحويلها إلى <dialog>.
التركيز بعد تغيّر المسار والحذف
حين يستبدل الـ router في Ticketly الصفحةَ الرئيسية بصفحة الدفع، يختفي الرابط الذي فعّله المستخدم، فيسقط التركيز إلى <body>. تبدأ ضغطة Tab التالية من أعلى المستند، ولا يُعلن قارئ الشاشة شيئًا. افعل ما كان تحميل الصفحة سيفعله:
function afterNavigate() {
const heading = document.querySelector('main h1');
document.title = `${heading.textContent} · Ticketly`;
heading.setAttribute('tabindex', '-1');
heading.focus();
}التركيز على h1 يُعلن اسم الصفحة الجديدة ويضع المستخدم في بداية محتواها. وtabindex="-1" تجعل العنوان قابلًا للتركيز من السكربت دون إضافة محطة لـ Tab. بعض الـ frameworks تفعل جزءًا من هذا نيابةً عنك؛ فتحقّق مما يفعله الـ framework الذي تستخدمه قبل أن تضيف كودك.
والمبدأ نفسه ينطبق على الحذف. حين يحذف المستخدم تذكرة من السلة، يختفي زر “Remove” الذي ضغطه. انقل التركيز إلى مكان منطقي، مثل زر Remove للعنصر التالي أو عنوان السلة، بدلًا من تركه يسقط إلى أعلى الصفحة.
العناصر الأصلية وبضعة أسطر لإدارة التركيز تغطّي معظم Ticketly. وحين لا يوجد عنصر أصلي، تحتاج إلى ARIA، وهذا موضوع الدرس التالي.
أهم النقاط
- حين يغيّر السكربت ما يظهر على الشاشة، يصبح هو المسؤول عن وجهة التركيز؛ فالمتصفّح لا يدير التركيز إلا عند تحميل صفحة كاملة.
- نافذة الحوار التي تُفتح بـ showModal() تنقل التركيز إلى داخلها، وتجعل بقية الصفحة inert، وتُغلق بمفتاح Escape، وتعيد التركيز إلى العنصر الذي فتحها حين تُغلق.
- خاصية inert تُخرج شجرة فرعية كاملة من التركيز والنقر وشجرة إمكانية الوصول، وهي مثالية للأدراج المغلقة واللوحات الواقعة خارج الشاشة.
- بعد تغيّر المسار في جهة العميل، حدّث document.title وانقل التركيز إلى عنوان h1 في الصفحة الجديدة (مع tabindex=“-1”).