أساسيات React.js
إمكانية الوصول في React
إمكانية الوصول في React
بناء تطبيقات React يمكن الوصول إليها يضمن أن الجميع، بما في ذلك الأشخاص ذوي الإعاقة، يمكنهم استخدام تطبيقك. تعلم كيفية تنفيذ سمات ARIA والتنقل بلوحة المفاتيح وإدارة التركيز واختبار إمكانية الوصول.
لماذا تهم إمكانية الوصول
إمكانية الوصول للويب (a11y) تفيد الجميع:
- الامتثال القانوني: تطلب العديد من الدول أن تكون المواقع قابلة للوصول
- جمهور أوسع: أكثر من مليار شخص لديهم إعاقات
- تجربة مستخدم أفضل: المواقع التي يمكن الوصول إليها أسهل في الاستخدام للجميع
- فوائد SEO: HTML الدلالي يحسن ترتيب البحث
- مقاومة للمستقبل: يعمل بشكل أفضل مع الأجهزة والسياقات الجديدة
HTML الدلالي
استخدم عناصر HTML المناسبة بدلاً من divs العامة:
// سيء - استخدام divs لكل شيء
function BadNavigation() {
return (
<div className="nav">
<div onClick={handleHome}>الرئيسية</div>
<div onClick={handleAbout}>حول</div>
<div onClick={handleContact}>اتصل</div>
</div>
);
}
// جيد - استخدام HTML الدلالي
function GoodNavigation() {
return (
<nav>
<ul>
<li><a href="/">الرئيسية</a></li>
<li><a href="/about">حول</a></li>
<li><a href="/contact">اتصل</a></li>
</ul>
</nav>
);
}
// استخدام العناوين الدلالية
function Article() {
return (
<article>
<h1>عنوان المقال الرئيسي</h1>
<section>
<h2>عنوان القسم</h2>
<p>المحتوى يأتي هنا...</p>
</section>
<section>
<h2>قسم آخر</h2>
<h3>قسم فرعي</h3>
<p>المزيد من المحتوى...</p>
</section>
</article>
);
}
ملاحظة: تستخدم قارئات الشاشة HTML الدلالي للتنقل وفهم بنية الصفحة. استخدم دائمًا عنصر HTML الأنسب للمهمة.
سمات ARIA
توفر سمات ARIA (تطبيقات الإنترنت الغنية التي يمكن الوصول إليها) سياقًا إضافيًا للتقنيات المساعدة:
import { useState } from 'react';
// زر يمكن الوصول إليه
function DeleteButton({ onDelete, itemName }) {
return (
<button
onClick={onDelete}
aria-label={`حذف ${itemName}`}
className="delete-btn"
>
×
</button>
);
}
// أكورديون مع ARIA
function Accordion({ title, children }) {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="accordion">
<button
onClick={() => setIsOpen(!isOpen)}
aria-expanded={isOpen}
aria-controls="accordion-content"
id="accordion-trigger"
>
{title}
</button>
<div
id="accordion-content"
role="region"
aria-labelledby="accordion-trigger"
hidden={!isOpen}
>
{children}
</div>
</div>
);
}
// حالة التحميل مع ARIA
function LoadingButton({ isLoading, onClick, children }) {
return (
<button
onClick={onClick}
disabled={isLoading}
aria-busy={isLoading}
aria-live="polite"
>
{isLoading ? (
<>
<span aria-hidden="true">⏳</span>
<span>جاري التحميل...</span>
</>
) : (
children
)}
</button>
);
}
نصيحة: استخدم aria-label للأزرار التي تحتوي على أيقونات فقط. استخدم aria-describedby لتوفير سياق إضافي. لا تستخدم أبدًا aria-hidden على عناصر قابلة للتركيز.
التنقل بلوحة المفاتيح
تأكد من أن جميع العناصر التفاعلية يمكن الوصول إليها بلوحة المفاتيح:
import { useState, useRef } from 'react';
function AccessibleTabs() {
const [activeTab, setActiveTab] = useState(0);
const tabRefs = useRef([]);
const tabs = ['الملف الشخصي', 'الإعدادات', 'الإشعارات'];
const handleKeyDown = (e, index) => {
let nextIndex;
switch (e.key) {
case 'ArrowRight':
nextIndex = (index + 1) % tabs.length;
break;
case 'ArrowLeft':
nextIndex = (index - 1 + tabs.length) % tabs.length;
break;
case 'Home':
nextIndex = 0;
break;
case 'End':
nextIndex = tabs.length - 1;
break;
default:
return;
}
e.preventDefault();
setActiveTab(nextIndex);
tabRefs.current[nextIndex]?.focus();
};
return (
<div>
<div role="tablist" aria-label="علامات تبويب الحساب">
{tabs.map((tab, index) => (
<button
key={tab}
ref={(el) => (tabRefs.current[index] = el)}
role="tab"
aria-selected={activeTab === index}
aria-controls={`panel-${index}`}
id={`tab-${index}`}
tabIndex={activeTab === index ? 0 : -1}
onClick={() => setActiveTab(index)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{tab}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
key={tab}
role="tabpanel"
id={`panel-${index}`}
aria-labelledby={`tab-${index}`}
hidden={activeTab !== index}
>
<h2>محتوى {tab}</h2>
<p>محتوى علامة التبويب {tab}</p>
</div>
))}
</div>
);
}
تحذير: لا تزيل أبدًا حدود التركيز باستخدام CSS ما لم تقدم مؤشر تركيز بديل مرئي. يحتاج المستخدمون إلى معرفة مكانهم في الصفحة.
إدارة التركيز
إدارة التركيز عندما يتغير المحتوى ديناميكيًا:
import { useState, useRef, useEffect } from 'react';
function Modal({ isOpen, onClose, title, children }) {
const modalRef = useRef(null);
const previousFocusRef = useRef(null);
useEffect(() => {
if (isOpen) {
// احفظ العنصر المركز حاليًا
previousFocusRef.current = document.activeElement;
// ركز على النافذة المنبثقة
modalRef.current?.focus();
// احتجز التركيز داخل النافذة
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
onClose();
}
if (e.key === 'Tab') {
const focusableElements = modalRef.current?.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
// استعد التركيز عند إغلاق النافذة
previousFocusRef.current?.focus();
};
}
}, [isOpen, onClose]);
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
ref={modalRef}
className="modal"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
tabIndex={-1}
onClick={(e) => e.stopPropagation()}
>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>إغلاق</button>
</div>
</div>
);
}
إمكانية الوصول للنماذج
اجعل النماذج قابلة للوصول مع التسميات المناسبة ومعالجة الأخطاء:
import { useState } from 'react';
function AccessibleForm() {
const [formData, setFormData] = useState({
email: '',
password: '',
terms: false,
});
const [errors, setErrors] = useState({});
const validateForm = () => {
const newErrors = {};
if (!formData.email) {
newErrors.email = 'البريد الإلكتروني مطلوب';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'البريد الإلكتروني غير صالح';
}
if (!formData.password) {
newErrors.password = 'كلمة المرور مطلوبة';
} else if (formData.password.length < 8) {
newErrors.password = 'يجب أن تكون كلمة المرور 8 أحرف على الأقل';
}
if (!formData.terms) {
newErrors.terms = 'يجب أن توافق على الشروط';
}
return newErrors;
};
const handleSubmit = (e) => {
e.preventDefault();
const newErrors = validateForm();
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
// ركز على حقل الخطأ الأول
const firstErrorField = Object.keys(newErrors)[0];
document.getElementById(firstErrorField)?.focus();
} else {
// إرسال النموذج
console.log('تم إرسال النموذج', formData);
}
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">
البريد الإلكتروني <span aria-label="مطلوب">*</span>
</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
required
/>
{errors.email && (
<span id="email-error" role="alert" className="error">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">
كلمة المرور <span aria-label="مطلوب">*</span>
</label>
<input
id="password"
type="password"
value={formData.password}
onChange={(e) =>
setFormData({ ...formData, password: e.target.value })
}
aria-invalid={!!errors.password}
aria-describedby={errors.password ? 'password-error' : undefined}
required
/>
{errors.password && (
<span id="password-error" role="alert" className="error">
{errors.password}
</span>
)}
</div>
<button type="submit">إرسال</button>
</form>
);
}
إعلانات قارئ الشاشة
استخدم المناطق المباشرة للإعلان عن تغييرات المحتوى الديناميكي:
import { useState } from 'react';
function LiveRegionExample() {
const [message, setMessage] = useState('');
const [items, setItems] = useState(['تفاح', 'موز', 'برتقال']);
const addItem = () => {
const newItem = `عنصر ${items.length + 1}`;
setItems([...items, newItem]);
setMessage(`تمت إضافة ${newItem} إلى القائمة`);
};
const removeItem = (index) => {
const removedItem = items[index];
setItems(items.filter((_, i) => i !== index));
setMessage(`تمت إزالة ${removedItem} من القائمة`);
};
return (
<div>
{/* إعلان قارئ الشاشة */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{message}
</div>
<h2>قائمة التسوق</h2>
<ul>
{items.map((item, index) => (
<li key={index}>
{item}
<button
onClick={() => removeItem(index)}
aria-label={`إزالة ${item}`}
>
إزالة
</button>
</li>
))}
</ul>
<button onClick={addItem}>إضافة عنصر</button>
</div>
);
}
نصيحة: استخدم aria-live="polite" للتحديثات غير العاجلة و aria-live="assertive" للمعلومات المهمة والحساسة للوقت.
قائمة التحقق من إمكانية الوصول
قائمة التحقق الأساسية من إمكانية الوصول:
- ✓ استخدم عناصر HTML الدلالية
- ✓ وفر بدائل نصية للصور (سمات alt)
- ✓ تأكد من تباين اللون الكافي (4.5:1 للنص العادي)
- ✓ اجعل جميع الوظائف قابلة للوصول بلوحة المفاتيح
- ✓ وفر مؤشرات تركيز مرئية
- ✓ استخدم تسلسل العناوين المناسب (h1، h2، h3...)
- ✓ قم بتسمية جميع إدخالات النموذج
- ✓ وفر رسائل الأخطاء والتعليمات
- ✓ استخدم سمات ARIA بشكل مناسب
- ✓ اختبر مع التنقل بلوحة المفاتيح
- ✓ اختبر مع قارئات الشاشة (NVDA، JAWS، VoiceOver)
- ✓ قم بإجراء اختبارات إمكانية الوصول التلقائية
تمرين 1: أنشئ مكون قائمة منسدلة يمكن الوصول إليه يعمل مع التنقل بلوحة المفاتيح (مفاتيح الأسهم، Enter، Escape). قم بتضمين سمات ARIA المناسبة وإدارة التركيز.
تمرين 2: قم ببناء جدول بيانات يمكن الوصول إليه مع أعمدة قابلة للفرز. قم بتضمين ترميز الجدول المناسب وعناصر التحكم بلوحة المفاتيح للفرز وإعلانات قارئ الشاشة عند تغيير ترتيب الفرز.
تمرين 3: نفذ دائري صور يمكن الوصول إليه مع عناصر تحكم تشغيل/إيقاف مؤقت والتنقل بلوحة المفاتيح (السابق/التالي) وإعلانات وصفية لقارئات الشاشة. اختبر مع قارئ شاشة لضمان الوظائف الصحيحة.