المقالات

إمكانية الوصول في React: ما يتجاهله معظم المطوّرين

إدارة التركيز، ولوحة المفاتيح، ومناطق ARIA الحيّة، والاختبار بقارئات الشاشة: أخطاء إمكانية الوصول في React التي تؤثّر في مستخدمين حقيقيّين.

مدة القراءة: 9 دقائق

صورة غلاف المقال: إمكانية الوصول في React: ما يتجاهله معظم المطوّرين

معظم نصائح إمكانية الوصول في React هي في الحقيقة نصائح للصفحات الثابتة. أضِف نصًا بديلًا، وسمِّ الحقول، وافحص التباين، وشغِّل Lighthouse. كل ذلك مهم، لكنه لا يمسّ المشكلات التي تجعل تطبيق React صعب الاستخدام بلوحة المفاتيح أو بقارئ الشاشة.

تنشأ هذه المشكلات من الشيء نفسه الذي يجعل React مفيدًا: تتغيّر الواجهة دون تحميل صفحة جديدة. تُفتح نافذة حوار، ويتغيّر المسار، ويظهر إشعار منبثق (toast)، وتُصفّى قائمة من تلقاء نفسها. كل تغيير من هذه واضح لمن ينظر إلى الشاشة، وغير مرئي لمن لا يراها، ما لم تُبلِغ عنه الشيفرة عمدًا.

يتناول هذا المقال الثغرات التي أراها أكثر من غيرها في قواعد شيفرة React: التركيز بعد التغييرات الديناميكية، وسلوك لوحة المفاتيح في المكوّنات المخصّصة، والمناطق الحيّة، وروتين اختبار بقارئات الشاشة يلتقط ما لا تستطيع الأدوات الآلية رؤيته.

لماذا تقصر معظم نصائح إمكانية الوصول في React

تُتقن أدوات الفحص الآلية مثل axe وLighthouse أمرًا واحدًا: فحص المستند في لحظة بعينها. فهي تجد التسميات الناقصة، والأزرار الفارغة، وسمات ARIA غير الصالحة، والتباين الضعيف. هذه عيوب حقيقية، وينبغي لكل مشروع أن يمنعها في التكامل المستمر (CI).

ما لا تراه أداة الفحص هو السلوك عبر الزمن. فهي لا تعرف أن التركيز سقط إلى body عند إغلاق نافذة الحوار، ولا تستطيع أن تكتشف أن القائمة المنسدلة المخصّصة تتجاهل مفاتيح الأسهم، أو أن مستخدم قارئ الشاشة لم يسمع شيئًا عند حفظ النموذج بنجاح. قد تحصل الصفحة على 100 وتبقى غير قابلة للاستخدام دون فأرة.

الحل هو التعامل مع إمكانية الوصول بوصفها تصميمًا للتفاعل، لا مجرّد سلامة للترميز. مع كل تغيير في حالة الواجهة، اطرح ثلاثة أسئلة:

  • أين يقع تركيز لوحة المفاتيح بعد هذا التغيير؟
  • كيف يعرف مستخدم قارئ الشاشة أن شيئًا قد تغيّر؟
  • هل يمكن تنفيذ كل إجراء أدّى إلى التغيير دون جهاز تأشير؟

يطبّق باقي المقال هذه الأسئلة على الأنماط التي تتعطّل أكثر من غيرها.

إدارة التركيز بعد تغييرات المحتوى الديناميكية

عندما يتغيّر المحتوى ويبقى التركيز في مكانه، يفقد مستخدمو لوحة المفاتيح وقارئات الشاشة موضعهم. وعندما يُحذَف محتوى كان التركيز داخله، يعود التركيز إلى body في المستند، فيبدأ الضغط التالي على Tab من أعلى الصفحة.

نوافذ الحوار الوسيطة (Modals)

يجب أن تنقل نافذة الحوار الوسيطة التركيز إلى داخلها عند فتحها، وأن تُبقيه داخلها ما دامت مفتوحة، وأن تُغلَق بمفتاح Escape، وأن تُعيد التركيز إلى العنصر الذي فتحها. تنفيذ ذلك يدويًا يعني كتابة مصيدة تركيز، وتطبيق inert على الخلفية، وإدارة قفل التمرير.

العنصر الأصيل <dialog> يتولّى معظم ذلك عنك. يجعل showModal() باقي الصفحة خاملًا، وينقل التركيز إلى نافذة الحوار، ويغلقها بمفتاح Escape. وتبقى مسؤولًا عن تفصيلة واحدة: إعادة التركيز بشكل موثوق وإبقاء حالة React متزامنة.

import { useEffect, useId, useRef, type ReactNode } from 'react';

interface ModalProps {
  open: boolean;
  onClose: () => void;
  title: string;
  children: ReactNode;
}

export function Modal({ open, onClose, title, children }: ModalProps) {
  const dialogRef = useRef<HTMLDialogElement>(null);
  const returnFocusRef = useRef<HTMLElement | null>(null);
  const titleId = useId();

  useEffect(() => {
    const dialog = dialogRef.current;
    if (!dialog) return;
    if (open && !dialog.open) {
      returnFocusRef.current = document.activeElement as HTMLElement | null;
      dialog.showModal();
    } else if (!open && dialog.open) {
      dialog.close();
    }
  }, [open]);

  const handleClose = () => {
    returnFocusRef.current?.focus();
    // Escape closes the dialog natively; tell the parent so state stays in sync.
    if (open) onClose();
  };

  return (
    <dialog ref={dialogRef} aria-labelledby={titleId} onClose={handleClose}>
      <h2 id={titleId}>{title}</h2>
      {children}
      <button type="button" onClick={onClose}>
        Close
      </button>
    </dialog>
  );
}

يُطلَق الحدث close سواء عيّن المكوّن الأب open إلى false أو ضغط المستخدم Escape، لذا يغطّي معالج واحد الحالتين. ويُبقي useId ربط العنوان صالحًا عندما توجد عدّة نوافذ حوار في الصفحة نفسها.

تغييرات المسار

في موقع يُعرَض على الخادم، تحمّل كل عملية تنقّل مستندًا جديدًا، وتُعلن قارئات الشاشة عنوان الصفحة. أمّا مع موجِّه يعمل على جهة العميل، فيتغيّر الرابط ويُستبدَل المحتوى، لكن التركيز يبقى على الرابط الذي نُقر عليه، وقد لا يكون موجودًا بعد الآن. وكثير من قارئات الشاشة لا تُعلن شيئًا.

انقل التركيز إلى العنوان الرئيسي بعد التنقّل، وحدِّث عنوان المستند:

import { useEffect, useRef, type ReactNode } from 'react';
import { useLocation } from 'react-router-dom';

interface PageHeadingProps {
  title: string;
  children: ReactNode;
}

export function PageHeading({ title, children }: PageHeadingProps) {
  const headingRef = useRef<HTMLHeadingElement>(null);
  const isFirstRender = useRef(true);
  const { pathname } = useLocation();

  useEffect(() => {
    document.title = title;
    if (isFirstRender.current) {
      isFirstRender.current = false;
      return;
    }
    headingRef.current?.focus();
  }, [pathname, title]);

  return (
    <h1 ref={headingRef} tabIndex={-1}>
      {children}
    </h1>
  );
}

تتيح القيمة tabIndex={-1} تركيز العنوان برمجيًا دون إضافته إلى ترتيب التنقّل بمفتاح Tab. وتخطّي العرض الأول مهم: عند التحميل الأوّلي، يتعامل المتصفّح وقارئ الشاشة مع المستند الجديد أصلًا، وسرقة التركيز حينها ستقفز بالمستخدم متجاوزةً رابط التخطّي إلى المحتوى وقائمة التنقّل.

الإشعارات المنبثقة والتحديثات المضمَّنة

يجب ألّا تأخذ الإشعارات المنبثقة التركيز. نقل التركيز إلى إشعار يقاطع ما كان المستخدم يفعله، ويتركه في منطقة ستختفي خلال ثوانٍ. أعلِن عن الإشعارات عبر منطقة حيّة، كما هو موضّح أدناه.

الاستثناء هو إشعار يحتوي على قرار مطلوب، مثل «تراجع عن الحذف». إذا كان الإجراء مهمًا، فلا ينبغي أن يكون في إشعار تنتهي مدّته. استخدم نافذة حوار، أو أبقِ عنصر التراجع في الصفحة حيث يستطيع المستخدم الوصول إليه.

التنقّل بلوحة المفاتيح في المكوّنات المخصّصة

تأتي العناصر الأصيلة مع سلوكها في لوحة المفاتيح. يستجيب <button> لمفتاحَي Enter والمسافة، ويستجيب <select> للأسهم وللبحث بالكتابة. عندما يستبدلها فريق بمكوّنات مخصّصة، غالبًا ما ينجو التصميم المرئي ولا ينجو عقد لوحة المفاتيح.

يوثّق دليل ممارسات التأليف في WAI-ARIA ‏(APG) سلوك لوحة المفاتيح المتوقَّع لكل عنصر واجهة مركّب. تعامَل معه بوصفه المواصفة، وتعامَل مع أدوار ARIA بوصفها وعدًا: إذا أضفت role="tablist" فسيتوقّع المستخدمون سلوك لوحة المفاتيح الخاص بعلامات التبويب.

علامات التبويب

تستخدم علامات التبويب tabIndex متنقّلًا (roving): علامة التبويب المحدّدة وحدها في ترتيب Tab، وتنقل مفاتيح الأسهم بين علامات التبويب. ثم ينقل Tab إلى اللوحة النشطة.

import {
  useId,
  useRef,
  useState,
  type KeyboardEvent,
  type ReactNode,
} from 'react';

interface TabItem {
  label: string;
  content: ReactNode;
}

export function Tabs({ items }: { items: TabItem[] }) {
  const [selected, setSelected] = useState(0);
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const baseId = useId();

  const select = (index: number) => {
    setSelected(index);
    tabRefs.current[index]?.focus();
  };

  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
    const last = items.length - 1;
    const targets: Record<string, number> = {
      ArrowRight: selected === last ? 0 : selected + 1,
      ArrowLeft: selected === 0 ? last : selected - 1,
      Home: 0,
      End: last,
    };
    const next = targets[event.key];
    if (next === undefined) return;
    event.preventDefault();
    select(next);
  };

  return (
    <div>
      <div role="tablist" aria-label="Project details" onKeyDown={onKeyDown}>
        {items.map((item, index) => (
          <button
            key={item.label}
            ref={(element) => {
              tabRefs.current[index] = element;
            }}
            type="button"
            role="tab"
            id={`${baseId}-tab-${index}`}
            aria-selected={index === selected}
            aria-controls={`${baseId}-panel-${index}`}
            tabIndex={index === selected ? 0 : -1}
            onClick={() => setSelected(index)}
          >
            {item.label}
          </button>
        ))}
      </div>
      {items.map((item, index) => (
        <div
          key={item.label}
          role="tabpanel"
          id={`${baseId}-panel-${index}`}
          aria-labelledby={`${baseId}-tab-${index}`}
          hidden={index !== selected}
          tabIndex={0}
        >
          {item.content}
        </div>
      ))}
    </div>
  );
}

هناك تفصيلتان يسهل إغفالهما. في التخطيطات من اليمين إلى اليسار، يجب أن يتبادل ArrowRight وArrowLeft معناهما، لذا اقرأ document.dir أو مرِّر الاتجاه كمعامل. وإذا كان تبديل علامة التبويب يُطلق طلبًا مكلفًا، فاستخدم التفعيل اليدوي: تنقل الأسهم التركيز، ويحدّد Enter أو المسافة.

مربّعات التحرير والسرد (Comboboxes)

مربّع التحرير والسرد هو أكثر عناصر الواجهة المخصّصة تعطّلًا. يُبقي نمط APG تركيز DOM في حقل النص، ويستخدم aria-activedescendant للإشارة إلى الخيار المميَّز، مع aria-expanded على الحقل وaria-controls يشير إلى القائمة. يفتح السهم للأسفل القائمة، ويحدّد Enter الخيار، ويغلق Escape القائمة دون مسح النص.

جعله يعمل جيدًا في كل تركيبة من قارئ الشاشة والمتصفّح يتطلّب أسابيع من الاختبار. ما لم يكن مربّع التحرير والسرد هو منتجك، فاستخدم تنفيذًا مُصانًا ومُختبَرًا مثل React Aria أو Downshift أو Radix، واصرف وقتك على الحالات المحيطة به: تحميل النتائج، وغياب النتائج، والأخطاء.

منتقيات التاريخ

قبل بناء منتقي تاريخ، تحقّق مما إذا كان <input type="date"> يلبّي المتطلَّب. فهو يمنحك إدخالًا بلوحة المفاتيح، وتنسيقًا محليًا، والتقويم المتاح في المنصّة دون تكلفة. أمّا شبكة التقويم المخصّصة فتحتاج إلى تنقّل بالأسهم حسب اليوم والأسبوع، وPage Up وPage Down حسب الشهر، وشبكة ذات تسمية، وإعلانًا عن التحديد. هذا قدر كبير من السلوك يجب صيانته لحقل يفضّل معظم المستخدمين الكتابة فيه.

مناطق ARIA الحيّة: متى تستخدمها ومتى لا تفعل

تطلب المنطقة الحيّة من التقنيات المساعِدة أن تُعلن تغييرات محتواها دون نقل التركيز. إنها الأداة المناسبة لرسائل الحالة: «تم الحفظ»، و«3 نتائج»، و«اكتمل الرفع».

الخطأ الأكثر شيوعًا هو إنشاء المنطقة الحيّة في اللحظة نفسها التي تُنشأ فيها الرسالة. تراقب قارئات الشاشة المناطق الموجودة أصلًا في المستند، والمنطقة التي تُدرَج مع نصّها غالبًا لا يُعلَن عنها. اعرض المنطقة مرة واحدة، واتركها فارغة، وغيِّر نصّها.

import {
  createContext,
  useCallback,
  useContext,
  useState,
  type ReactNode,
} from 'react';

const AnnounceContext = createContext<(message: string) => void>(() => {});

export function AnnouncerProvider({ children }: { children: ReactNode }) {
  const [message, setMessage] = useState('');

  const announce = useCallback((next: string) => {
    setMessage('');
    // Clearing first lets the same message be announced twice in a row.
    requestAnimationFrame(() => setMessage(next));
  }, []);

  return (
    <AnnounceContext.Provider value={announce}>
      {children}
      <div role="status" className="visually-hidden">
        {message}
      </div>
    </AnnounceContext.Provider>
  );
}

export const useAnnounce = () => useContext(AnnounceContext);

الدور role="status" منطقة حيّة مهذّبة: تنتظر حتى ينهي قارئ الشاشة الجملة الحالية. ويجب أن تُخفي الفئة visually-hidden النص بصريًا دون إزالته من شجرة إمكانية الوصول، لذا استخدم أداة مساعدة قائمة على القصّ (clip)، لا display: none. بعد ذلك يستدعي أي مكوّن announce('Changes saved') بعد حفظ ناجح.

ومعرفة متى لا تستخدمها لا تقلّ أهمية:

  • لا تستخدم role="alert" للرسائل الروتينية. الإعلانات الحازمة (assertive) تقاطع المستخدم في منتصف الجملة. احتفظ بها للأخطاء التي تتطلّب انتباهًا فوريًا.
  • لا تضع aria-live على حاويات كبيرة. المنطقة الحيّة التي تحيط بقائمة نتائج كاملة ستقرأ بصوت عالٍ كل صف يتغيّر.
  • لا تُعلن ما يوصله التركيز أصلًا. إذا نقلت التركيز إلى عنوان أو إلى ملخّص أخطاء، فسيقرؤه قارئ الشاشة. والإعلان عنه مرة أخرى يكرّر الرسالة.
  • لا تستخدم المناطق الحيّة لأخطاء النموذج عند الإرسال. انقل التركيز إلى ملخّص أخطاء في أعلى النموذج، مع روابط إلى كل حقل غير صالح. بهذا يحصل المستخدم على قائمة يمكنه التنقّل فيها، لا جملة يسمعها مرة واحدة.

سير عمل الاختبار بقارئات الشاشة

لا تحتاج إلى أن تكون مستخدمًا خبيرًا لقارئات الشاشة كي تجد الأخطاء المهمة. تحتاج إلى روتين قابل للتكرار، يُطبَّق على المسارات المهمة، باستخدام التركيبتين اللتين يعتمد عليهما معظم المستخدمين.

NVDA مع Chrome على Windows

NVDA مجاني وواسع الانتشار. ثبّته، وافتح تطبيقك في Chrome، وفعِّل عارض الكلام من قائمة NVDA لقراءة ما يُعلَن.

  • تنقّل في المسار الرئيسي بمفتاح Tab، واستمع إلى اسم كل عنصر تحكّم ودوره وحالته.
  • اضغط H للتنقّل بين العناوين في وضع التصفّح، وNVDA+F7 لفتح قائمة العناصر. يجب أن تكون البنية منطقية دون الجانب المرئي.
  • شغِّل كل تغيير ديناميكي: افتح نافذة الحوار وأغلقها، وغيّر المسار، واحفظ النموذج، وطبّق مرشّحًا. تأكّد من أن كل تغيير يُعلَن عنه أو أن التركيز ينتقل إلى مكان منطقي.

VoiceOver مع Safari على macOS

يأتي VoiceOver مدمجًا في macOS. شغّله وأوقفه بالضغط على Command+F5. مفتاح التعديل الخاص بـ VoiceOver هو Control+Option.

  • استخدم VO+السهم الأيمن للتنقّل في المحتوى بشكل خطّي.
  • افتح الدوّار (Rotor) بالضغط على VO+U لمراجعة العناوين والروابط والمناطق وعناصر تحكّم النماذج.
  • كرّر فحوص التغييرات الديناميكية نفسها التي أجريتها مع NVDA. يعرض Safari وChrome شجرة إمكانية الوصول بطرق مختلفة، لذا غالبًا ما تظهر الأخطاء في تركيبة واحدة فقط.

دوِّن المسارات: تسجيل الدخول، والبحث، وإكمال النموذج الرئيسي، والتعافي من خطأ. شغِّلها قبل كل إصدار، وأضِف جزء لوحة المفاتيح إلى الاختبارات الشاملة (end-to-end) كي لا تتعطّل دون أن يلاحظ أحد. توضّح قائمة مراجعة الشيفرة الأمامية أين تندرج فحوص لوحة المفاتيح والتركيز في المراجعة، وتطبّق دراسة حالة مسار التواصل المتاح للجميع طريقة التفكير نفسها على التسميات والتحقّق ووسائل التواصل البديلة.

قائمة تدقيق عملية

استخدم هذه القائمة في أي شاشة React تتغيّر دون تحميل صفحة جديدة:

  • يدخل التركيز إلى نافذة الحوار عند فتحها ويعود إلى العنصر المُشغِّل عند إغلاقها.
  • يغلق Escape كل نافذة حوار ونافذة منبثقة (popover) وقائمة.
  • بعد تغيير المسار على جهة العميل، ينتقل التركيز إلى عنوان الصفحة الجديدة ويُحدَّث عنوان المستند.
  • لا تأخذ الإشعارات المنبثقة التركيز أبدًا، ولكل إشعار يتضمّن إجراءً بديلٌ لا تنتهي مدّته.
  • يتّبع كل عنصر واجهة مخصّص نمط لوحة المفاتيح الخاص بدوره في APG.
  • علامة تبويب واحدة فقط من كل قائمة علامات تبويب في ترتيب Tab، وتحترم مفاتيح الأسهم اتجاه النص.
  • تستخدم مربّعات التحرير والسرد مكتبة مُختبَرة أو تنفيذًا لـ aria-activedescendant مُختبَرًا جيدًا.
  • توجد المناطق الحيّة في المستند قبل أن تتغيّر رسائلها.
  • يُحتفَظ بـ role="alert" للأخطاء العاجلة.
  • تنقل أخطاء النموذج عند الإرسال التركيز إلى ملخّص أخطاء فيه روابط إلى كل حقل.
  • يُختبَر كل تغيير ديناميكي باستخدام NVDA مع Chrome، وVoiceOver مع Safari.
  • تغطّي الاختبارات الشاملة مسارات لوحة المفاتيح في التدفّقات الحرجة.

الخلاصات الرئيسية

  • تفحص أدوات الفحص الآلية الترميز في لحظة بعينها، ومعظم أخطاء إمكانية الوصول في React تحدث بين لحظة وأخرى.
  • كل تغيير في الحالة يحتاج إلى قرار بشأن التركيز وبشأن طريقة الإعلان عنه.
  • يحلّ العنصر الأصيل <dialog> معظم سلوك نافذة الحوار الوسيطة، وتبقى مسؤولًا عن إعادة التركيز ومزامنة الحالة.
  • يجب أن تنفّذ عناصر الواجهة المخصّصة عقد لوحة المفاتيح الكامل الذي يعد به دور ARIA الخاص بها، أو أن تستخدم مكتبة مُختبَرة.
  • يجب أن توجد المناطق الحيّة قبل أن يتغيّر محتواها، ويجب أن تكون معظم الرسائل مهذّبة.
  • تركيبتا قارئ الشاشة، NVDA مع Chrome وVoiceOver مع Safari، تلتقطان معظم المشكلات الحقيقية.