إنشاء مكونات متاحة لقارئات الشاشة
إنشاء مكونات متاحة لقارئات الشاشة
تخيل مستخدمًا يعتمد على NVDA يفتح نافذة منبثقة (modal) تحتوي على نموذج، يملؤه، ثم ينقر على "إغلاق". تختفي النافذة بصريًا – display: none في CSS، وكل شيء يبدو سليمًا. إلا أن العنصر ما زال موجودًا في DOM، ولم ينتقل التركيز (focus) إلى أي مكان، وقارئ الشاشة يواصل قراءة محتوى النافذة التي "أُغلقت" للتو. يسمع المستخدم محتوى نموذج تخلّص منه للتو، وليس لديه أدنى فكرة عن مكانه الحالي في الصفحة. هذا ليس مثالًا افتراضيًا – إنه واحد من أكثر الأخطاء شيوعًا في المكونات المبنية "بالعين"، دون فهم حقيقي لكيفية عمل إتاحة الوصول. في هذا المقال أريد أن أتجاوز قائمة القواعد الجافة وأُظهر الآلية التي تقف خلفها – إلى جانب بضعة مكونات حقيقية ترون فيها بالضبط أين تكمن الفخاخ.
كيف يرى قارئ الشاشة الصفحة فعليًا
قبل إصلاح المكونات، من المفيد معرفة ما الذي يتعامل معه قارئ الشاشة فعليًا. فهو لا يعرض الصفحة كما يفعل المتصفح، بل يعمل على شجرة إتاحة الوصول (accessibility tree)، وهي بنية يبنيها المتصفح بالتوازي مع DOM. لكل عقدة في هذه الشجرة ثلاث خصائص أساسية: الدور (role) – هل هو زر، رابط، عنوان، أم حقل نموذج؛ الاسم (name) – ما يسمعه المستخدم كوصف له؛ والحالة (state) – موسّع، محدد، أم معطّل.
والنتيجة الأساسية المترتبة على ذلك: قارئ الشاشة لا يرى CSS الخاص بك. يؤدي display: none وvisibility: hidden إلى إزالة العنصر من شجرة إتاحة الوصول – وهذه طريقة صحيحة لإخفاء شيء ما. لكن مجرد نقل عنصر خارج حدود الشاشة (position: absolute; left: -9999px) أو جعل لونه transparent لا يغيّر شيئًا في شجرة إتاحة الوصول – يبقى العنصر موجودًا ويُقرأ كما هو. وهذا بالضبط ما حدث خطؤه في مثال النافذة المنبثقة أعلاه.
الركيزة الثانية هي ترتيب DOM. في وضع التصفح، يقرأ قارئ الشاشة الصفحة بالترتيب الذي تظهر فيه العناصر في الشجرة – بغض النظر عن كيفية ترتيبك لها بصريًا باستخدام flex-direction: row-reverse أو grid-template-areas. فإذا كان التخطيط منطقيًا بصريًا لكن ترتيب المصدر عشوائي، فسيحصل المستخدم الكفيف على الصفحة "بترتيب عشوائي".
الدلالة (Semantics) كخط دفاع أول
أرخص طريقة لتحقيق إتاحة وصول صحيحة هي استخدام عنصر HTML الصحيح بدلاً من إعادة اختراع سلوكه من الصفر. لاحظ الفرق:
jsx
// سيئ – يبدو كزر، لكنه ليس كذلكconst SaveButton = ({ onSave }) => (<div className="btn" onClick={onSave}>حفظ التغييرات</div>);
يحمل هذا الـdiv ثغرات جدية ليست واضحة للوهلة الأولى:
- غير قابل للتركيز (focusable) – لا يمكن الوصول إليه بمفتاح Tab.
- لا يستجيب للوحة المفاتيح – مفتاحا Enter وSpace لا يفعلان شيئًا، لأن
onClickفي React يستمع فقط لأحداث الفأرة/اللمس. - لا يملك دور
button– يقرأه قارئ الشاشة كنص عادي، دون أي إشارة إلى إمكانية "تفعيله". - لا يملك حالة تعطيل – لا يمكنك ببساطة ضبط
disabled، بل عليك محاكاتها يدويًا.
jsx
// جيد – تحصل على كل ما سبق مجانًاconst SaveButton = ({ onSave, isSaving }) => (<button type="button" onClick={onSave} disabled={isSaving}>{isSaving ? "جارٍ الحفظ…" : "حفظ التغييرات"}</button>);
المبدأ نفسه ينطبق على <nav> بدلاً من <div className="nav">، أو <label> مرتبط بحقل عبر htmlFor بدلاً من placeholder يتظاهر بأنه تسمية، أو <ul>/<li> للقوائم بدلاً من كومة من عناصر <div>. HTML الدلالي ليس "أجمل" فحسب – إنه يولّد فعليًا بنية مختلفة وأغنى في شجرة إتاحة الوصول.
ARIA: متى تساعد، ومتى تضر
تفتتح مواصفة WAI-ARIA بقاعدة تستحق أن تُحفظ حرفيًا: "لا ARIA أفضل من ARIA سيئة" (No ARIA is better than Bad ARIA). سمات ARIA لا تضيف سلوكًا – بل تكتفي بتجاوز ما يبلّغه قارئ الشاشة للمستخدم. فإذا وعدت بشيء لا يستطيع المكوّن تقديمه فعليًا، يصبح وضعك أسوأ مما لو لم تضف شيئًا على الإطلاق.
jsx
// سيئ – ARIA تَعِد بزر، لكن لا شيء آخر يدعم ذلكconst DeleteIcon = ({ onDelete }) => (<span role="button" aria-label="حذف العنصر" onClick={onDelete}>🗑</span>);
يخبر هذا الكود قارئ الشاشة بأن "هذا زر" – لكنه لا يضيف أي دعم للوحة المفاتيح ولا tabIndex، لذا لن يتمكن مستخدم لوحة المفاتيح من الوصول إليه أبدًا. وهذا أسوأ من عدم وجود role على الإطلاق، لأنه يعطي انطباعًا بأن الميزة موجودة بينما هي غير قابلة للوصول فعليًا.
jsx
// جيد – استخدم ببساطة زرًا أصليًاconst DeleteIcon = ({ onDelete }) => (<button type="button" onClick={onDelete} aria-label="حذف العنصر"><span aria-hidden="true">🗑</span></button>);
لاحظ aria-hidden="true" على الإيموجي – فبدونه، تحاول بعض قارئات الشاشة قراءة اسم رمز اليونيكود ("سلة مهملات")، وهو ما يبدو غريبًا مباشرة بعد أن أُعلن aria-label بالفعل. تحل aria-label محل المحتوى المرئي بالكامل فيما يسمعه المستخدم – فإذا كان العنصر يملك بالفعل نصًا مقروءًا، يكون aria-labelledby المشير إلى ذلك النص الخيار الأفضل عادةً، حتى لا تضطر إلى صيانة وصفين مستقلين قد يتباعدان مع الوقت.
مثال عملي: أكورديون متاح
زر بسيط لا يُظهر الكثير. لننظر إلى مكوّن يتطلب فعلاً إدارة الحالة وعلاقات ARIA بشكل متعمد – أكورديون، وهو نمط القسم القابل للتوسيع الشائع في الأسئلة الشائعة أو لوحات الإعدادات.
jsx
import { useId, useState } from "react";const AccordionItem = ({ title, children, defaultOpen = false }) => {const [isOpen, setIsOpen] = useState(defaultOpen);const contentId = useId();return (<div className="accordion-item"><h3 className="accordion-header"><buttontype="button"className="accordion-trigger"aria-expanded={isOpen}aria-controls={contentId}onClick={() => setIsOpen((open) => !open)}>{title}<span className="accordion-icon" aria-hidden="true">{isOpen ? "−" : "+"}</span></button></h3><div id={contentId} role="region" aria-labelledby={contentId} hidden={!isOpen}>{children}</div></div>);};export default AccordionItem;
بعض القرارات في هذا الكود ليست عشوائية:
aria-expandedيُبلّغ عن الحالة الحالية – فبدونه، يسمع مستخدم قارئ الشاشة كلمة "زر" فقط، دون أي طريقة لمعرفة ما إذا كان القسم مفتوحًا.aria-controlsيربط الزر باللوحة التي يتحكم بها – تُعلن بعض قارئات الشاشة عن هذه العلاقة، مما يسهّل التنقل.hidden(بدلًا من الإخفاء عبر CSS فقط) يضمن أن المحتوى المغلق يُزال فعليًا من شجرة إتاحة الوصول ومن ترتيب Tab، بحيث لا يمكن أن يستقر التركيز في محتوى غير مرئي.- إحاطة
<h3>بالزر تحافظ على سلامة تسلسل العناوين الهرمي – إذ كثيرًا ما يتنقل مستخدمو قارئات الشاشة عبر العناوين، منتقلين بين الأقسام بمفتاح H. - أيقونة
+/−تحملaria-hidden="true"، لأن الحالة أصلاً تُنقل عبرaria-expanded– وبدون هذا، سيُعلن قارئ الشاشة عنها مرتين، بشكل مربك.
المناطق الحية والرسائل الديناميكية
مشكلة شائعة أخرى: يتغيّر شيء ما في الصفحة دون إعادة تحميل، ولا يلاحظ قارئ الشاشة ذلك أبدًا، لأنه لا سبب يدفعه لإعادة قراءة جزء لا يستكشفه المستخدم حاليًا. مثال كلاسيكي هو خطأ تحقق يظهر ديناميكيًا بعد مغادرة حقل ما:
jsx
import { useState } from "react";const EmailField = () => {const [error, setError] = useState("");const handleBlur = (event) => {const value = event.target.value;setError(value.includes("@") ? "" : "أدخل عنوان بريد إلكتروني صالحًا.");};return (<div className="field"><label htmlFor="email">البريد الإلكتروني</label><inputid="email"type="email"aria-invalid={Boolean(error)}aria-describedby={error ? "email-error" : undefined}onBlur={handleBlur}/><span id="email-error" role="alert" className="field-error">{error}</span></div>);};export default EmailField;
يجعل role="alert" العنصر يتصرف كمنطقة aria-live="assertive" ضمنية – فعند تغيّر محتواه، يقاطع قارئ الشاشة أي شيء يقرؤه حاليًا ويُعلن الرسالة الجديدة فورًا. هذا مناسب للأخطاء التي تتطلب انتباهًا عاجلاً، لكن لا تفرط في استخدامه للتحديثات الأقل إلحاحًا (مثل "تم حفظ المسودة") – فـaria-live="polite" أنسب هناك، لأنه ينتظر حتى ينهي المستخدم إجراءه الحالي بدلاً من مقاطعته. كما يربط aria-describedby رسالة الخطأ بالحقل، بحيث يقرأها قارئ الشاشة مع التسمية في كل مرة يعود فيها المستخدم إلى ذلك الحقل – وليس فقط في لحظة ظهور الخطأ لأول مرة.
إدارة التركيز عند التنقل في Next.js
هذا الفخ خاص بتطبيقات الصفحة الواحدة (SPA)، بما فيها Next.js App Router. في الانتقال التقليدي بين الصفحات (إعادة تحميل كاملة)، يعيد المتصفح ضبط التركيز إلى <body>، ويُعلن قارئ الشاشة عن عنوان المستند الجديد – فيعرف المستخدم أنه وصل إلى صفحة جديدة. أما مع التنقل من جهة العميل، فلا شيء من ذلك يحدث تلقائيًا: يبقى التركيز على الرابط الذي نُقر عليه (غالبًا في مكان ما ضمن قائمة التنقل، خارج المحتوى الجديد)، ولا يتلقى قارئ الشاشة أي إشارة بأن شيئًا قد تغيّر أصلًا.
jsx
"use client";import { usePathname } from "next/navigation";import { useEffect, useRef } from "react";const RouteAnnouncer = ({ pageTitle }) => {const pathname = usePathname();const headingRef = useRef(null);useEffect(() => {headingRef.current?.focus();}, [pathname]);return (<h1 ref={headingRef} tabIndex={-1} className="visually-focusable-heading">{pageTitle}</h1>);};export default RouteAnnouncer;
الحيلة هنا هي tabIndex={-1} – فالعناوين ليست قابلة للتركيز عادةً، لكن هذه القيمة تتيح نقل التركيز إليها برمجيًا (.focus()) دون إضافتها إلى ترتيب Tab الطبيعي. بعد كل تغيير في المسار (pathname)، يعود التركيز إلى عنوان الصفحة الجديدة، فيُعلن قارئ الشاشة عن عنوانها – تمامًا كما يحدث في إعادة التحميل التقليدية. إنه مكوّن واحد يستحق إضافته إلى التخطيط (layout) مرة واحدة وعدم التفكير فيه مجددًا أبدًا.
كيف تختبر إتاحة الوصول فعليًا
تستحق الأدوات الآلية مثل axe-core أو eslint-plugin-jsx-a11y أن تُفعَّل منذ اليوم الأول للمشروع – فهي تكتشف الأخطاء الواضحة (alt مفقود، تباين ألوان ضعيف، تسميات مفقودة) قبل أن تصل إلى الإنتاج. لكن انتبه لحدودها: وفقًا لأبحاث شركة Deque Systems، تكتشف الأدوات الآلية عمليًا نحو 30–40% فقط من مشكلات إتاحة الوصول. أما الباقي فيتطلب فحصًا يدويًا، لأنه يتعلق بالمعنى والسياق الذي لا يمكن لآلة تقييمه – هل ترتيب القراءة منطقي فعلًا، وهل تشرح رسالة الخطأ فعلًا ما يجب فعله، وهل فخ التركيز في نافذة منبثقة يمنع المستخدم فعليًا من الإفلات منه.
عملية ملموسة وقابلة للتكرار تلتقط معظم المشكلات الواقعية:
- ضع الفأرة جانبًا واسلك المسار الكامل باستخدام لوحة المفاتيح فقط – Tab، Shift+Tab، Enter، Space، Escape، ومفاتيح الأسهم أينما كان ذلك طبيعيًا (كما في قائمة). إذا لم تعرف في أي لحظة أين يقع التركيز، فهذا خلل بحد ذاته.
- فعّل VoiceOver (على macOS: Cmd+F5) أو NVDA (على Windows، مجانًا) واسلك المسار نفسه وعيناك مغمضتان. هذا وحده يكشف ما إذا كان الترتيب والأسماء والحالات منطقية فعلًا عند سماعها، لا على الورق فقط.
- اختبر الرسائل الديناميكية بشكل منفصل – أطلق خطأ نموذج، غيّر المسار، افتح نافذة منبثقة – وتحقق مما إذا كان قارئ الشاشة قد أعلن فعلًا عن شيء، لا فقط من أن العنصر يحمل السمة الصحيحة في الكود.
الخلاصة
المكونات المتاحة ليست قائمة سمات تُلصق في النهاية – بل هي نتيجة طبيعية لكيفية نمذجتك للحالة والبنية منذ البداية. ثلاثة أمور تستحق أن تأخذها من هذا المقال: أولًا، يمنحك HTML الدلالي إمكانية التركيز ودعم لوحة المفاتيح والدور الصحيح مجانًا – وينبغي أن تملأ ARIA ما لا يستطيع HTML التعبير عنه، لا أن تحل محله في مكونات كان يمكن أن تكون ببساطة عنصرًا أصليًا. ثانيًا، يجب الإعلان بفاعلية عن التغييرات في الحالة والمحتوى – فـaria-live وrole="alert" وإدارة التركيز بعد التنقل ليست إضافات كمالية، بل هي الشرط الذي يجعل التطبيق الديناميكي قابلًا للاستخدام أصلًا من دون بصر. ثالثًا، لا تُغني أي أداة آلية عن استعراض واجهتك بنفسك وعيناك مغمضتان – فهذه أسرع طريقة لترى بالضبط أين يفقد مكونك "المتاح" المستخدم فعليًا.