EduFlow

الوحدة 3 · لوحة المفاتيح والتركيز وARIA

إدارة التركيز: نوافذ الحوار والتطبيقات أحادية الصفحة

20 دقراءة 4 دبيئة تجريب الكود

الدرس في لمحة

انقل تركيز لوحة المفاتيح عن قصد حين يتغيّر المحتوى دون تحميل صفحة جديدة، باستخدام عنصر dialog الأصلي وخاصية inert وإدارة التركيز عند تغيّر المسار.

  1. 1النافذة التي لا تحبس أحدًا
  2. 2inert لكل ما سوى ذلك
  3. 3التركيز بعد تغيّر المسار والحذف

لا فيديو هنا، بل الشيء الحقيقي: درس مكتوب وأمثلة قابلة للتشغيل واختبار وتدريب.

في موقع الويب التقليدي، يتولّى المتصفّح أمر التركيز نيابةً عنك. كل نقرة على رابط تحمّل صفحة جديدة، فيعود التركيز إلى الأعلى، ويُعلن قارئ الشاشة العنوان الجديد. لكن Ticketly تطبيق أحادي الصفحة (single-page app) فيه نوافذ منبثقة وأدراج وتوجيه في جهة العميل، فلا يحدث شيء من ذلك. حين يغيّر كودك الشاشة، يقرّر كودك أين يذهب التركيز. وإن لم يقرّر، يبقى التركيز حيث كان، وكثيرًا ما يكون ذلك على شيء صار مخفيًا أو مغطّى أو محذوفًا.

النافذة التي لا تحبس أحدًا

يفتح زر “Book now” في Ticketly نافذة حجز منبثقة: div عليه طبقة معتمة. راقب ما يحدث بلوحة المفاتيح. تظهر النافذة، لكن التركيز ما زال على “Book now” خلفها. ينتقل Tab إلى رابط المهرجان التالي في الصفحة التي تحتها. ولا يقول قارئ شاشة سمير شيئًا عن أي نافذة؛ فبالنسبة له لم يحدث شيء.

نافذة الحوار المشروطة (modal dialog) الصحيحة تحتاج إلى خمسة سلوكيات:

  1. ينتقل التركيز إلى داخل النافذة حين تُفتح.
  2. يبقى Tab وShift+Tab داخلها ما دامت مفتوحة.
  3. تُخفى الصفحة التي خلفها عن التقنيات المساعدة.
  4. يُغلقها مفتاح Escape.
  5. يعود التركيز إلى عنصر التحكّم الذي فتحها.

لسنوات كان هذا يتطلّب مكتبة لحبس التركيز وخمسين سطرًا من الكود. أما اليوم فعنصر <dialog> الأصلي المفتوح بـ showModal() يفعل السلوكيات الخمسة كلها.

ticketly/booking.html
<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>
ticketly/booking.js
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 الطبقة المعتمة.

يبدأ التركيز على زر Book now. ينقله showModal إلى أول حقل في النافذة، حيث يدور Tab داخل النافذة بينما الصفحة inert. ويعيد الإغلاق أو Escape التركيز إلى زر Book now. "Book now" dialog (الطبقة العليا) Quantity → Confirm → Close → Quantity showModal() close / Esc الصفحة خلفها inert لا يغادر Tab النافذة ما دامت مفتوحة
التركيز خلال حياة نافذة الحوار

أعطِ كل نافذة حوار اسمًا بـ aria-labelledby تشير إلى عنوانها. حين تُفتح، يقرأ NVDA ‏"Book Friday pass, dialog, Quantity, spin button, 1"، وهذا يخبر سمير بالضبط أين هو. وإن لم يكن أول حقل في النافذة أفضل نقطة بداية، كنافذة شروط طويلة مثلًا، فضع autofocus على العنصر الذي يجب أن يتلقّى التركيز، مثل زر الإغلاق فيها، أو عنوانها بعد جعله قابلًا للتركيز بـ tabindex="-1" لتبدأ القراءة من الأعلى.

inert لكل ما سوى ذلك

ليست كل لوحة مخفية نافذة حوار. تنزلق قائمة الهاتف في Ticketly خارج الشاشة حين تُغلق، لكن روابطها ما زالت في الـ DOM وما زالت قابلة للتركيز، فيمرّ مستخدمو لوحة المفاتيح على ثمانية روابط غير مرئية. خاصية inert تُطفئ شجرة فرعية كاملة: لا تركيز، ولا نقر، ولا شجرة إمكانية وصول.

HTML
<nav id="mobile-menu" class="drawer" inert>…</nav>
JavaScript
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 التالية من أعلى المستند، ولا يُعلن قارئ الشاشة شيئًا. افعل ما كان تحميل الصفحة سيفعله:

ticketly/router.js
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”).

قراءات إضافية

تحقّق من فهمك

4 أسئلة

السؤال 1 من 4

يضغط مستخدم لوحة المفاتيح Enter على “Book now”، فيُشغَّل هذا الكود. أين يكون التركيز بعد ذلك؟

JavaScript
bookButton.addEventListener('click', () => {
  document.querySelector('.modal').hidden = false; // a div styled as a modal
});
السؤال 2 من 4

أيّ هذه الأشياء لا يفعله dialog.showModal() نيابةً عنك؟

السؤال 3 من 4

تنزلق قائمة الهاتف في Ticketly خارج الشاشة حين تُغلق، لكن Tab ما زال يزور روابطها الثمانية وهي غير مرئية. ما أبسط إصلاح متين؟

السؤال 4 من 4

بعد تنقّل في جهة العميل من الصفحة الرئيسية إلى ‎"/checkout"، ماذا يجب أن يفعل الـ router في Ticketly لمستخدمي قارئ الشاشة؟

تدريب

بيئة تجريب الكود

حوّل نافذة الحجز إلى نافذة حوار حقيقية

نافذة الحجز في Ticketly عنصر div يظهر حين تضغط Book now. يبقى التركيز خلفها، وتبقى الصفحة التي تحتها قابلة للوصول، ولا تُبلَّغ قارئات الشاشة بأن نافذة حوار فُتحت.

حوّل #booking إلى عنصر <dialog> أصلي (أبقِ الـ id) له اسم متاح مأخوذ من عنوانه “Book Friday pass”. افتحه كنافذة مشروطة (modal) من زر Book now وأغلقه من زر Close. وحين يُغلق، يجب أن يعود التركيز إلى Book now.

<main>
  <h1>Harbour Lights: Friday</h1>
  <p>Standing tickets, SAR 180 each.</p>
  <button type="button" id="book">Book now</button>
  <a href="#faq">Festival FAQ</a>
</main>

<div class="modal" id="booking" hidden>
  <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>
</div>جارٍ تحميل المحرّر…

Ctrl/⌘ + Enter للتشغيل · يعمل في بيئة معزولة داخل متصفّحك. لا شيء يغادر جهازك.

المعاينة

الـ console

(لا مخرجات)

الفحوص

  • ‎#booking عنصر اسمه “Book Friday pass”
  • زر Book now يفتحه كنافذة حوار مشروطة
  • ينتقل التركيز إلى داخل النافذة حين تُفتح
  • لا تستطيع الصفحة التي خلفها تلقّي التركيز ما دامت النافذة مفتوحة
  • زر Close يُغلق النافذة ويعود التركيز إلى Book now