> ## Content Index
> Fetch the complete content index at: https://arabroot.io/llms.txt
> Use this file to discover other available public pages before exploring further.

# سهولة الوصول Accessibility: موقع وتطبيق يستخدمه الجميع
- URL: https://arabroot.io/articles/سهولة-الوصول-accessibility-كيف-تبني-موقعا-يستخدمه-الجميع/
- Published: 2026-10-13T07:45:00.000Z
- Updated: 2026-10-13T07:43:23.000Z
- Description: كيف تبني صفحات وتطبيقات يستخدمها الجميع بلوحة المفاتيح وقارئ الشاشة، من عناصر HTML الأصلية إلى النوافذ والنماذج العربية.
- Author: فريق عرب رووت
- Tags: الأدلة التقنية, DevOps والتشغيل

في مقال [اختبار سهولة الوصول Accessibility لموقع عربي](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%84%D9%85%D9%88%D9%82%D8%B9-%D8%B9%D8%B1%D8%A8%D9%8A/) أخذنا صفحة جاهزة وفحصناها بالأدوات وبلوحة المفاتيح، فوجدنا مشاكلها بعد أن بنيت، وأصلحناها واحدة واحدة. وهذا الترتيب مكلف، لأن أغلب تلك المشاكل كان يمكن ألا توجد أصلاً لو اختار المطور العنصر الصحيح من البداية، وكتب الخطأ في النموذج نصاً بدل أن يكتفي بلون أحمر، وفكر في أين يذهب التركيز Focus عندما تفتح نافذة. لذلك هذا المقال عن البناء وليس عن الفحص: كيف تكتب المكونات التي تتكرر في كل موقع وتطبيق بحيث يستخدمها الجميع من أول مرة، بالفأرة ولوحة المفاتيح وقارئ الشاشة Screen Reader والجوال.

وسوف نناقش في هذا المقال ما يلي:

- من يستفيد من سهولة الوصول بالأرقام، ولماذا يخصك حتى لو لم يكن موقعك حكومياً.
- القاعدة الأولى: عناصر HTML الأصلية قبل أي شيء آخر، ثم قواعد ARIA ومتى تضر أكثر مما تنفع.
- لوحة المفاتيح والتركيز Focus: رابط التخطي Skip Link، وترتيب التركيز Focus في صفحة عربية، والنوافذ المنبثقة Modal Dialogs، والتبويبات Tabs مع الأسهم في اتجاه RTL.
- النماذج ورسائل الخطأ، والتباين، وعدم الاعتماد على اللون وحده.
- النص البديل بالعربية، والترجمة النصية Captions للفيديو، وتقليل الحركة Reduced Motion.
- تطبيقات الجوال مع TalkBack وVoiceOver، وحجم الخط، وحجم الهدف Target الذي يضغط عليه المستخدم.
- ما يخص العربية: اللغة والاتجاه والأرقام والتشكيل والأيقونات.
- تجربة عملية نبني فيها نافذة وتبويبات ونموذجاً عربياً بطريقة خاطئة، ثم نعيد بناءها، ونختبر النسختين بلوحة المفاتيح عبر Playwright، وبشجرة سهولة الوصول Accessibility Tree، وبأداة axe.
- من يملك سهولة الوصول في الفريق، وكيف تدخل في نظام التصميم Design System وفي تعريف الإنجاز Definition of Done.

## من يستفيد من سهولة الوصول؟

شرحنا تعريف سهولة الوصول ومن تخدمه في فقرة [ما هي سهولة الوصول، ولمن؟](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%84%D9%85%D9%88%D9%82%D8%B9-%D8%B9%D8%B1%D8%A8%D9%8A/#what-is)، ومنها رقم [منظمة الصحة العالمية](https://www.who.int/news-room/fact-sheets/detail/disability-and-health?ref=arabroot.io) المعروف: نحو 1.3 مليار شخص يعيشون مع إعاقة كبيرة، أي 16% من سكان العالم أو شخص من كل ستة. ولكن هذا الرقم يحسب الإعاقة الكبيرة فقط، وهناك رقمان آخران من المنظمة نفسها يوضحان لماذا يحتاج كل موقع أن يهتم بالموضوع:

- **ضعف البصر:** تقول [صحيفة المنظمة عن العمى وضعف البصر](https://www.who.int/news-room/fact-sheets/detail/blindness-and-visual-impairment?ref=arabroot.io) المنشورة في 10 فبراير 2026 إن 2.2 مليار شخص على الأقل في العالم لديهم ضعف في الرؤية القريبة أو البعيدة، وإن أغلب من لديهم ضعف بصر أو عمى تجاوزوا الخمسين من العمر. وهؤلاء هم من يكبرون الخط ويحتاجون تبايناً كافياً.
- **كبار السن:** تقول [صحيفة الشيخوخة والصحة](https://www.who.int/news-room/fact-sheets/detail/ageing-and-health?ref=arabroot.io) المنشورة في 1 أكتوبر 2025 إن شخصاً من كل ستة في العالم سوف يكون عمره 60 سنة أو أكثر بحلول 2030، وإن عددهم سوف يتضاعف إلى 2.1 مليار بحلول 2050\. وكلهم سوف يستخدمون تطبيق البنك والحجوزات والخدمات الحكومية.

الصورة التالية تجمع هذه الفئات مع الحالات المؤقتة والظرفية التي يمر بها أي مستخدم:

![ثلاث مجموعات من المستخدمين: الدائمة مثل الكفيف وضعيف البصر ومن لا يستطيع استخدام الفأرة، والمؤقتة مثل يد مكسورة أو عملية في العين، والظرفية مثل جوال في الشمس أو مكان لا يمكن فيه تشغيل الصوت، وتحتها أرقام منظمة الصحة العالمية: 1.3 مليار بإعاقة كبيرة، و2.2 مليار على الأقل بضعف في الرؤية، وشخص من كل ستة عمره 60 سنة أو أكثر بحلول 2030](https://arabroot.io/content/images/2026/10/accessibility-basics-01-who.webp)

الأرقام من صحائف منظمة الصحة العالمية، والفئات الثلاث تتداخل: الشخص نفسه قد يمر بها كلها في يوم واحد

وقد يتساءل البعض: هل هذا كله إلزامي؟ والإجابة أن الجهات الحكومية في السعودية مطالبة بالمستوى AA كما شرحنا في فقرة [دليل هيئة الحكومة الرقمية](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%84%D9%85%D9%88%D9%82%D8%B9-%D8%B9%D8%B1%D8%A8%D9%8A/#saudi)، ولكن حتى لو لم يكن موقعك حكومياً فالعميل الذي لا يستطيع إكمال الطلب لن يشتري، والسبب في الغالب ليس عيباً كبيراً في التصميم، وإنما زر لا يصله مفتاح Tab أو حقل لا يعرف المستخدم ماذا يكتب فيه.

## ابدأ بالعناصر الأصلية Semantic HTML قبل أي شيء

قارئ الشاشة لا يرى الصفحة كما تراها أنت، وإنما يقرأ شجرة سهولة الوصول Accessibility Tree التي يبنيها المتصفح من الـ HTML، وفيها لكل عنصر ثلاثة أشياء: الدور Role مثل زر أو رابط أو حقل نص، والاسم المتاح Accessible Name مثل «حفظ العنوان»، والحالة State مثل محدد أو معطل أو مفتوح. وعندما تكتب العنصر الصحيح يملأ المتصفح هذه الثلاثة وحده، ويضيف معها سلوك لوحة المفاتيح، أما عندما تكتب `div` وتضيف عليه `onclick` فأنت تحصل على شكل زر بدون أي شيء من ذلك.

والجدول التالي يبين ما تحصل عليه مجاناً من العنصر الأصلي:

| ما تريد بناءه        | العنصر الأصلي                   | ما يعطيك إياه المتصفح دون كود إضافي                                                         |
| -------------------- | ------------------------------- | ------------------------------------------------------------------------------------------- |
| زر ينفذ عملاً        | العنصر <button>                 | يصله Tab، ويعمل بزر Enter والمسافة Space، ودوره «زر» عند قارئ الشاشة، ويرسل النموذج         |
| انتقال إلى صفحة أخرى | العنصر <a href>                 | يصله Tab، ويفتح بزر Enter، ويمكن فتحه في تبويب جديد، ويظهر في قائمة الروابط عند قارئ الشاشة |
| اسم الحقل            | العنصر <label for>              | الاسم المتاح للحقل، والضغط على النص يضع التركيز Focus في الحقل فتكبر مساحة الضغط            |
| مجموعة خيارات        | العنصران <fieldset> و<legend>   | اسم للمجموعة يقرأ مع خياراتها، وأزرار الاختيار داخلها يتنقل بينها المستخدم بالأسهم          |
| نافذة فوق الصفحة     | العنصر <dialog> مع showModal()  | الصفحة خلفها تصبح غير متاحة Inert، وزر Escape يغلقها، والتركيز Focus يعود عند الإغلاق       |
| أقسام الصفحة         | العناصر <header> و<nav> و<main> | معالم Landmarks يقفز بينها مستخدم قارئ الشاشة مباشرة                                        |
| عناوين الأقسام       | العناصر من <h1> إلى <h6>        | جدول محتويات يتنقل به مستخدم قارئ الشاشة من قسم إلى قسم                                     |

وسوف ترى في التجربة أن أغلب الإصلاح كان استبدال عناصر `div` و`span` بعناصر أصلية، وأن مكوناً واحداً فقط، وهو التبويبات Tabs، احتاج ARIA لأنه لا يوجد عنصر HTML أصلي له.

## قواعد ARIA: لا ARIA أفضل من ARIA خاطئة

كلمة ARIA اختصار Accessible Rich Internet Applications، وهي خصائص مثل `role` و`aria-label` و`aria-selected` تضيفها على العناصر لتصف لقارئ الشاشة ما لا يستطيع HTML وصفه وحده. وأول صفحة في [دليل ممارسات ARIA من W3C](https://www.w3.org/WAI/ARIA/apg/practices/read-me-first/?ref=arabroot.io)، واختصاره APG، عنوانها «No ARIA is better than Bad ARIA»، أي أن عدم استخدام ARIA أفضل من استخدامها بشكل خاطئ، والسبب أن ARIA تغير ما يسمعه مستخدم قارئ الشاشة فقط، ولا تغير شيئاً في سلوك العنصر.

ويشرح الدليل ذلك بمبدأين:

- **الدور وعد:** كتابة `<div role="button">` وعد من المطور بأنه كتب JavaScript يعطي هذا العنصر سلوك الزر بلوحة المفاتيح، لأن الأدوار في ARIA بخلاف عناصر HTML لا تجعل المتصفح يضيف أي سلوك أو تنسيق. ويشبه الدليل الدور الذي لا يفي بوعده بزر «إتمام الطلب» يلغي الطلب ويفرغ السلة.
- **ARIA تغطي وتضيف:** بعضها مثل الغطاء يخفي المعنى الأصلي، فالرابط الذي عليه `role="menuitem"` لا يعود رابطاً عند قارئ الشاشة، والرابط الذي عليه `aria-label` يقرأ بنص الخاصية فقط وليس بنصه الظاهر. وبعضها يضيف معنى مفيداً، مثل `aria-pressed` على زر كتم الصوت.

أما القواعد المختصرة التي يحفظها المطورون فهي «قواعد استخدام ARIA» من وثيقة [Using ARIA](https://www.w3.org/TR/using-aria/?ref=arabroot.io)، ولاحظ أن الوثيقة نفسها توقف العمل عليها وتقول إنها أبقت القواعد للرجوع إليها، وتحيل إلى دليل APG لكل ما عدا ذلك:

1. إذا كان هناك عنصر HTML أصلي أو خاصية فيها المعنى والسلوك الذي تحتاجه، فاستخدمها بدل أن تضيف ARIA على عنصر آخر.
2. لا تغير المعنى الأصلي للعنصر إلا للضرورة، فلا تكتب `role="tab"` على عنوان `h2`.
3. كل عنصر تفاعلي بنيته بـ ARIA يجب أن يعمل بلوحة المفاتيح.
4. لا تضع `aria-hidden="true"` أو `role="presentation"` على عنصر يصله التركيز Focus، وإلا وصل المستخدم إلى «لا شيء».

الصورة التالية تبين الفرق بين الزر الأصلي وزر مصنوع من `div` عليه ARIA، وكم سطراً تحتاج لتفي بالوعد:

![مقارنة بين عمودين: في الأول عنصر button أصلي بسطر واحد يحصل على الدور والاسم والوصول بمفتاح Tab والتفعيل بزر Enter والمسافة، وفي الثاني div عليه role بقيمة button يحصل على الدور فقط، وتحته قائمة ما يجب أن يكتبه المطور بنفسه: tabindex ومعالج لوحة المفاتيح ومنع تمرير الصفحة بالمسافة وحالة التعطيل، ثم سلم القرار: عنصر أصلي أولاً، ثم نمط من دليل APG، ثم الاختبار بلوحة المفاتيح](https://arabroot.io/content/images/2026/10/accessibility-basics-02-aria.webp)

الدور في ARIA وعد بسلوك، والعنصر الأصلي يفي بالوعد دون كود

ومتى تحتاج ARIA فعلاً؟ في ثلاث حالات غالباً: مكون لا يوجد له عنصر أصلي مثل التبويبات Tabs وشجرة الملفات Tree View، وحالة متغيرة يجب أن يسمعها المستخدم مثل `aria-expanded` على زر قائمة و`aria-invalid` على حقل فيه خطأ، وربط عنصرين مثل `aria-describedby` الذي يجعل رسالة الخطأ تقرأ مع الحقل، و`role="status"` الذي يعلن رسالة «تم الحفظ» دون أن ينتقل إليها التركيز Focus كما يطلب [المعيار 4.1.3 Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html?ref=arabroot.io) من المستوى AA.

## التركيز Focus ولوحة المفاتيح

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

### رابط التخطي Skip Link إلى المحتوى

إذا كان في رأس كل صفحة عشرون رابطاً للقوائم، فمستخدم لوحة المفاتيح يضغط Tab عشرين مرة قبل أن يصل إلى المحتوى في كل صفحة يفتحها. و[المعيار 2.4.1 Bypass Blocks](https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html?ref=arabroot.io) من المستوى A يطلب طريقة لتخطي هذه الأجزاء المتكررة، وأبسطها رابط التخطي Skip Link، وهو أول عنصر في الصفحة، مخفي حتى يصله التركيز Focus، ويشير إلى `main`:

```html
  .skip { position: absolute; inset-inline-start: 8px; top: -60px; padding: 10px 16px; background: #1d1d1f; color: #fff; border-radius: 6px; }
  .skip:focus { top: 8px; }
```

```html
<a class="skip" href="#main">انتقل إلى المحتوى</a>
```

```html
<main id="main" tabindex="-1">
```

ولاحظ `tabindex="-1"` على `main`، والسبب أن العنصر `main` لا يستقبل التركيز Focus بطبيعته، وبهذه القيمة ينتقل إليه التركيز Focus عند الضغط على الرابط دون أن يصبح محطة إضافية لزر Tab، وهذا ما يتحقق منه أول اختبار في التجربة. ولاحظ أيضاً `inset-inline-start` بدل `left`، فالرابط يظهر في بداية السطر، أي يميناً في الصفحة العربية ويساراً في الإنجليزية، دون ملفي CSS.

### ترتيب التركيز Focus Order في صفحة عربية

يطلب [المعيار 2.4.3 Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html?ref=arabroot.io) من المستوى A أن ينتقل التركيز Focus بترتيب يحفظ المعنى، والمتصفح ينقل التركيز Focus بترتيب العناصر في الـ HTML وليس بترتيب ظهورها على الشاشة. وفي الصفحة العربية التي عليها `dir="rtl"` يبدأ السطر من اليمين، فإذا كان ترتيب الكود هو ترتيب القراءة فسوف يتحرك Tab من اليمين إلى اليسار ومن الأعلى إلى الأسفل كما يقرأ المستخدم، دون أي إعداد إضافي.

والمشكلة تبدأ عندما يصلح المطور الشكل بـ CSS بدل أن يصلح الترتيب، وأشهر الأمثلة:

- **عكس الصف بالخاصية `flex-direction: row-reverse`** أو بالخاصية `order` لأن العناصر ظهرت بترتيب خاطئ في الواجهة العربية: الشكل يصبح صحيحاً، والتركيز Focus يقفز في الاتجاه المعاكس لما يراه المستخدم.
- **قيمة موجبة للخاصية `tabindex`** مثل `tabindex="3"`: تأخذ العنصر من مكانه الطبيعي في الترتيب وتضعه قبل كل شيء آخر، ودليل APG يقول إن القيم الأكبر من صفر غير مستحسنة إطلاقاً، فاستخدم `0` لتجعل العنصر يستقبل التركيز Focus في مكانه، و`-1` لتنقل إليه التركيز Focus من JavaScript فقط.
- **بناء الواجهة بالإنجليزية ثم عكسها بالخاصية `transform: scaleX(-1)`** على العنصر الأب كله: الشكل معكوس والكود بترتيبه الإنجليزي، والأفضل كتابة الخصائص المنطقية Logical Properties مثل `margin-inline-start` كما شرحنا في [سؤال CSS بالعربية والإنجليزية](https://arabroot.io/articles/%D8%A7%D8%B3%D8%A6%D9%84%D8%A9-%D9%85%D9%82%D8%A7%D8%A8%D9%84%D8%A9-%D9%85%D8%B7%D9%88%D8%B1-frontend-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%AE%D8%A8%D9%8A%D8%B1/#fe-logical).

### النوافذ المنبثقة Modal Dialogs وحصر التركيز Focus داخلها

النافذة المنبثقة Modal Dialog أكثر مكون يكسر لوحة المفاتيح، لأن الصفحة تبقى خلفها في الكود، ويحدد [نمط النافذة في دليل APG](https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/?ref=arabroot.io) ما يجب أن يحدث بالضبط:

- عند الفتح ينتقل التركيز Focus إلى عنصر داخل النافذة، غالباً أول عنصر تفاعلي، أو العنوان إذا كان المحتوى طويلاً.
- زر Tab وShift+Tab يتنقلان بين عناصر النافذة فقط، والصفحة خلفها غير متاحة Inert.
- زر Escape يغلق النافذة.
- عند الإغلاق يعود التركيز Focus إلى العنصر الذي فتح النافذة، إلا إذا حذف ذلك العنصر، فينتقل إلى عنصر منطقي آخر.

كان تنفيذ هذا يدوياً يحتاج عشرات الأسطر: حفظ العنصر الذي فتح النافذة، وحصر التركيز Focus Trap، ووضع `aria-hidden` على باقي الصفحة. أما اليوم فالعنصر `<dialog>` مع الدالة `showModal()` يفعل أغلبه، فبحسب [وثائق MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/dialog?ref=arabroot.io) تجعل النافذة المفتوحة بهذه الدالة باقي الصفحة غير متاح Inert، ويغلقها زر Escape، بينما النافذة التي تفتح بالدالة `show()` ليست Modal ولا يغلقها Escape افتراضياً. وسوف نرى الفرق بين الدالتين في التجربة، ونرى أيضاً تفصيلاً صغيراً: Chromium لا يدور بالتركيز Focus داخل النافذة مباشرة كما يصف APG، وإنما يخرج به خارج الصفحة خطوة واحدة ثم يعيده إلى أول عنصر، ولا يصل أبداً إلى الصفحة خلفها.

### التبويبات Tabs واتجاه الأسهم في الواجهة العربية

لا يوجد عنصر HTML أصلي للتبويبات Tabs، لذلك نبنيها بالأدوار `tablist` و`tab` و`tabpanel` كما في [نمط التبويبات في دليل APG](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/?ref=arabroot.io)، وأهم ما فيه سلوك لوحة المفاتيح: قائمة التبويبات كلها محطة Tab Stop واحدة، فيدخلها Tab على التبويب المحدد، ويخرج منها Tab التالي إلى محتوى التبويب، وبين التبويبات ينتقل المستخدم بالأسهم، وزر Home إلى الأول وEnd إلى الأخير.

وهنا مشكلة تخص العربية: الدليل يقول إن السهم الأيسر ينقل إلى التبويب السابق والأيمن إلى التالي، ولا يذكر ماذا يحدث في واجهة من اليمين إلى اليسار، وهناك بلاغ مفتوح بهذا في مستودع الدليل منذ 2020 برقم [1437](https://github.com/w3c/aria-practices/issues/1437?ref=arabroot.io) عن الأسهم في اللغات التي تكتب من اليمين، وبلاغ آخر فتح في أغسطس 2026 برقم [3464](https://github.com/w3c/aria-practices/issues/3464?ref=arabroot.io) يسأل عن التبويبات نفسها، وكلاهما مفتوح وقت كتابة هذا المقال. وفي غياب نص صريح اخترنا في التجربة أن يتبع السهم الاتجاه المرئي، فالسهم الأيسر في الواجهة العربية ينقل إلى التبويب التالي لأن التالي يقع على اليسار، وهذا ما وجدناه أيضاً في سلوك Chromium مع أزرار الاختيار Radio Buttons الأصلية في التجربة. والأهم من الاختيار نفسه أن تكون مكونات التطبيق كلها متسقة، وأن تكتبه في وثائق نظام التصميم Design System.

الصورة التالية تلخص حركة التركيز Focus في المكونين:

![لوحتان: في الأولى زر حذف العنوان يفتح نافذة، فينتقل التركيز Focus إلى زر إلغاء، وTab يتنقل داخل النافذة والصفحة خلفها معتمة وغير متاحة، وEscape يغلق النافذة ويعيد التركيز Focus إلى زر الحذف، وفي الثانية ثلاثة تبويبات من اليمين إلى اليسار: الطلبات ثم العناوين ثم الإعدادات، وTab يدخل على التبويب المحدد، والسهم الأيسر ينقل إلى التالي، والسهم الأيمن إلى السابق، وTab التالي يخرج إلى محتوى التبويب](https://arabroot.io/content/images/2026/10/accessibility-basics-03-focus.webp)

النافذة تحصر التركيز Focus وتعيده، والتبويبات محطة Tab واحدة يتنقل داخلها المستخدم بالأسهم

## النماذج ورسائل الخطأ

النموذج هو المكان الذي يخسر فيه الموقع المستخدم غالباً، لأن الخطأ فيه يمنع إكمال المهمة. والحل البسيط الذي نراه كثيراً هو حقل بتلميح Placeholder بدل التسمية Label، وحدود حمراء حول الحقل الخاطئ، وهذا لا يعمل لثلاثة أسباب: التلميح يختفي عند أول حرف، والحدود الحمراء لا يراها من لا يميز الأحمر ولا يسمعها قارئ الشاشة، ولا أحد يعرف ما هو الخطأ أصلاً. لذلك يطلب [المعيار 3.3.1 Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html?ref=arabroot.io) من المستوى A أن يحدد الحقل الخاطئ وأن يوصف الخطأ للمستخدم نصاً.

والطريقة الصحيحة لكل حقل كما يلي:

- تسمية Label ظاهرة مربوطة بالحقل عبر `for`، والتلميح الدائم مثل صيغة الرقم في فقرة تحت التسمية Label وليس في Placeholder.
- التلميح ورسالة الخطأ مربوطان بالحقل عبر `aria-describedby`، فيقرأ قارئ الشاشة الاسم ثم الوصف عندما يصل التركيز Focus إلى الحقل.
- عند الخطأ تصبح `aria-invalid="true"` على الحقل، وتظهر رسالة نصية تقول ما المطلوب، مع أيقونة وحدود أعرض وليس لوناً فقط.
- عند الإرسال بأخطاء يظهر ملخص في أعلى النموذج فيه رابط لكل حقل خاطئ، وينتقل إليه التركيز Focus، فيعرف المستخدم عدد الأخطاء من أول جملة.
- عند النجاح تظهر الرسالة في عنصر `role="status"` فتعلن دون أن ينتقل إليها التركيز Focus.
- الحقول التي يكتب فيها رقم تقبل الأرقام العربية ٠١٢٣ والإنجليزية 0123 معاً، وعليها `inputmode="numeric"` لتفتح لوحة الأرقام على الجوال، و`dir="ltr"` لأن رقم الجوال يكتب من اليسار. وأما `autocomplete` فشرحناه في [إصلاح صفحة الحجز](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%84%D9%85%D9%88%D9%82%D8%B9-%D8%B9%D8%B1%D8%A8%D9%8A/#lab-fix).

الصورة التالية تبين أجزاء الحقل الواحد وكيف ترتبط ببعضها في شجرة سهولة الوصول Accessibility Tree:

![حقل رقم الجوال مع أجزائه: التسمية Label فوقه مربوطة بالخاصية for، والتلميح تحتها، ورسالة الخطأ بأيقونة تحذير ونص أحمر، وأسهم توضح أن التلميح والخطأ مربوطان بالحقل عبر aria-describedby، وبجانبه ما يصل إلى قارئ الشاشة: الدور حقل نص، والاسم من التسمية Label، والحالة غير صالح، والوصف من التلميح ونص الخطأ، وفي الأعلى ملخص الأخطاء بروابط إلى الحقول](https://arabroot.io/content/images/2026/10/accessibility-basics-04-form.webp)

الاسم من التسمية Label، والوصف من التلميح ورسالة الخطأ، والحالة من aria-invalid

## التباين وعدم الاعتماد على اللون وحده

نسب التباين للنص، 4.5:1 للنص العادي و3:1 للنص الكبير، شرحناها في [فقرة المستويات](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%84%D9%85%D9%88%D9%82%D8%B9-%D8%B9%D8%B1%D8%A8%D9%8A/#wcag)، وشرحنا هناك أيضاً أن axe لم يفحص [تباين الجمل العربية](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%84%D9%85%D9%88%D9%82%D8%B9-%D8%B9%D8%B1%D8%A8%D9%8A/#lab-arabic-contrast) في تجربتنا، فلن نكرر ذلك هنا. وما يهمك وأنت تبني هو معياران آخران يغفل عنهما المصممون كثيراً:

- **اللون ليس الوسيلة الوحيدة:** يطلب [المعيار 1.4.1 Use of Color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html?ref=arabroot.io) من المستوى A ألا يكون اللون الطريقة الوحيدة لنقل معلومة أو تمييز عنصر. فالتبويب المحدد الذي يتميز بلون أخضر فقط، والحقل الخاطئ بحدود حمراء فقط، والرابط داخل الفقرة الذي يتميز بلونه فقط، كلها تحتاج علامة ثانية: خط أعرض، أو شريط، أو أيقونة، أو نص.
- **تباين عناصر الواجهة:** يطلب [المعيار 1.4.11 Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html?ref=arabroot.io) من المستوى AA تبايناً لا يقل عن 3:1 لما يحتاجه المستخدم ليعرف عناصر الواجهة وحالاتها، مثل حدود الحقل وإطار التركيز Focus والأيقونات التي تحمل معنى. والحقل بحدود رمادية فاتحة على خلفية بيضاء من أكثر الأخطاء شيوعاً، لأن المستخدم لا يعرف أين يضغط.

وفي التجربة غيرنا حدود الحقول من `#ccc` إلى `#767676`، وأضفنا للتبويب المحدد خطاً عريضاً وشريطاً بسماكة 4 بكسل، ولرسالة الخطأ أيقونة ونصاً.

## النص البديل والترجمة النصية للفيديو

قاعدة النص البديل `alt` شرحناها مع مثال الشعار في [إصلاح صفحة الحجز](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%84%D9%85%D9%88%D9%82%D8%B9-%D8%B9%D8%B1%D8%A8%D9%8A/#lab-fix): صف وظيفة الصورة وليس شكلها، واجعله فارغاً `alt=""` إذا كانت زخرفة أو كان النص بجانبها يقول المعنى نفسه. وفي الموقع العربي أضف إلى ذلك أمرين: اكتب النص البديل بلغة الصفحة نفسها، فالنص البديل الإنجليزي في صفحة `lang="ar"` سوف يقرؤه قارئ الشاشة بلغة الصفحة، واختصر، فالنص البديل الطويل يقرأ دفعة واحدة دون أن يستطيع المستخدم التنقل فيه، والصورة المعقدة مثل الرسم البياني تحتاج نصاً بديلاً قصيراً ووصفاً كاملاً في الفقرة أو في جدول تحتها.

أما الفيديو فيطلب [المعيار 1.2.2 Captions (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/captions-prerecorded.html?ref=arabroot.io) من المستوى A ترجمة نصية لكل فيديو مسجل فيه صوت، ويوضح المعيار أن الترجمة النصية Captions لا تشمل الكلام فقط، وإنما من يتكلم والأصوات المهمة غير الكلام. وتفرق [وثائق MDN للعنصر track](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/track?ref=arabroot.io) بين نوعين: الترجمة Subtitles لمن يسمع الصوت ولا يفهم اللغة، والترجمة النصية Captions لمن لا يسمع الصوت أصلاً، وفيها المؤثرات الصوتية ومصدر الصوت. والمثال التالي فيديو بالعربية بترجمة نصية عربية وترجمة إنجليزية:

```html
<video controls src="intro.mp4">
  <track kind="captions" src="intro.ar.vtt" srclang="ar" label="العربية" default>
  <track kind="subtitles" src="intro.en.vtt" srclang="en" label="English">
</video>
```

وملف الترجمة النصية بصيغة WebVTT يبدو كما يلي، ولاحظ اسم المتكلم والصوت غير الكلامي بين قوسين:

```text
WEBVTT

00:00:01.000 --> 00:00:04.000
<v المدرب>أهلاً بكم، سوف نبدأ بإعداد السيرفر.

00:00:04.500 --> 00:00:06.000
[صوت تنبيه من الجهاز]
```

وإذا استخدمت التوليد الآلي للترجمة النصية بالعربية فراجعه قبل النشر، وخاصة الأسماء والمصطلحات التقنية والأرقام، والسبب أن من يعتمد على الترجمة النصية وحدها لا يملك طريقة ليعرف أنها خاطئة.

## تقليل الحركة Reduced Motion لمن يطلبه

بعض الناس يصيبهم الدوار أو الغثيان من الحركة على الشاشة، مثل نافذة تنزلق من الأعلى أو خلفية تتحرك مع التمرير، ولذلك تعطي الأنظمة إعداداً لتقليل الحركة، ويقرؤه الموقع عبر استعلام [prefers-reduced-motion](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion?ref=arabroot.io) الذي تصبح قيمته `reduce` عندما يفعل المستخدم الإعداد. والطريقة الأسهل أن تكتب الحركة كما تريد، ثم تلغيها أو تخففها لمن طلب ذلك:

```html
  dialog[open] { animation: pop .25s ease-out; }
  @keyframes pop { from { transform: scale(.96); opacity: 0; } to { transform: none; opacity: 1; } }
  @media (prefers-reduced-motion: reduce) {
    dialog[open] { animation: none; }
  }
```

وفي WCAG معياران عن الحركة: [المعيار 2.2.2 Pause, Stop, Hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html?ref=arabroot.io) من المستوى A، ويطلب طريقة لإيقاف أي محتوى يتحرك وحده أكثر من خمس ثوان بجانب محتوى آخر، مثل شريط الأخبار أو العروض المتحركة Carousel، و[المعيار 2.3.3 Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html?ref=arabroot.io) من المستوى AAA، ويطلب إمكانية إيقاف الحركة التي يسببها تفاعل المستخدم. والثاني ليس من AA، ولكن تكلفته سطر CSS واحد كما رأيت، فلا يوجد سبب لتركه.

## تطبيقات الجوال مع TalkBack وVoiceOver وحجم الخط

قارئ الشاشة على Android هو TalkBack وعلى iOS هو VoiceOver، وكلاهما يعمل بالفكرة نفسها التي رأيتها في الويب: لكل عنصر دور واسم وحالة. وفي [وثائق Android](https://developer.android.com/guide/topics/ui/accessibility/apps?ref=arabroot.io) الاسم يأتي من الخاصية `contentDescription` للأيقونات والصور، وTalkBack يقرأ النص الظاهر وحده دون حاجة لوصف، والعنصر الذي للزخرفة فقط يعطى `null` حتى لا يكرر القارئ كلاماً بلا فائدة. والاختبارات على الجوال تستفيد من الأسماء نفسها كما رأينا في [معرف الوصول Accessibility ID في Appium](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84-%D9%85%D8%B9-appium-%D9%88-maestro/#appium).

وهناك أمران يخصان الجوال أكثر من الويب:

- **حجم الخط:** المستخدم يكبر الخط من إعدادات الجهاز، وتطلب [إرشادات Apple](https://developer.apple.com/design/human-interface-guidelines/accessibility?ref=arabroot.io) أن يستطيع المستخدم تكبير النص 200% على الأقل في iOS، إما بواجهة خاصة أو بدعم الخاصية Dynamic Type التي تتبع إعداد النظام. ولا تقم بتثبيت ارتفاع الأزرار والبطاقات التي فيها نص عربي، والسبب أن السطر الثاني سوف يقص عند أول تكبير.
- **حجم الهدف Target:** الإصبع أقل دقة من الفأرة، والجدول التالي يبين الحد في كل مرجع.

| المرجع                                                                                                                 | الحد الأدنى للهدف Target                           | ملاحظات                                                                                                                                      |
| ---------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| معيار [WCAG 2.5.8](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html?ref=arabroot.io) من المستوى AA | مساحة 24 في 24 من وحدات البكسل في CSS              | مع استثناءات: مسافة كافية حول الهدف Target الأصغر، أو زر بديل بالحجم المطلوب، أو رابط داخل جملة، أو حجم يحدده المتصفح، أو شكل ضروري للمعلومة |
| إرشادات Apple لنظام iOS                                                                                                | مساحة 44 في 44 نقطة                                | الحجم الافتراضي للعنصر، والحد الأدنى المطلق 28 في 28 نقطة                                                                                    |
| وثائق Android                                                                                                          | مساحة 48 في 48 من وحدة dp المستقلة عن كثافة الشاشة | والأكبر أفضل، ويمكن أن يكون أصغر للفأرة ولوحة اللمس                                                                                          |

والصورة التالية تقارن الأحجام الثلاثة على شاشة جوال:

![شاشة جوال عليها ثلاثة أزرار حذف بأحجام مختلفة: 24 بكسل وهو الحد الأدنى في WCAG، و44 نقطة في إرشادات Apple، و48 وحدة dp في Android، وبجانبها إصبع يغطي الزر الصغير بالكامل، وتحتها شريط تكبير الخط إلى 200% مع بطاقة نص عربي يتمدد ارتفاعها بدل أن يقص](https://arabroot.io/content/images/2026/10/accessibility-basics-05-mobile.webp)

الحد الأدنى في WCAG لا يكفي للمس دائماً، ولذلك استخدمنا 44 بكسل في التجربة

## ما يخص العربية

شرحنا في المقال السابق أغلب المشاكل الخاصة بالمواقع العربية، فلن نكررها، وإنما نجمعها هنا مع ما يخص البناء:

- **اللغة والاتجاه:** `lang="ar"` و`dir="rtl"` على العنصر `html` وليس في CSS، كما في فقرة [لغة الصفحة واتجاهها](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%84%D9%85%D9%88%D9%82%D8%B9-%D8%B9%D8%B1%D8%A8%D9%8A/#lang-dir)، وعزل النص الإنجليزي والأرقام داخل الجمل العربية كما في فقرة [خلط الاتجاهين Bidi](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%84%D9%85%D9%88%D9%82%D8%B9-%D8%B9%D8%B1%D8%A8%D9%8A/#bidi).
- **الأرقام:** اختر شكلاً واحداً للأرقام في الواجهة كلها، ولا تعتمد على أن رمز اللغة يختاره لك، فكما سوف ترى في التجربة يعطي الرمز `ar` وحده أرقاماً إنجليزية، ويعطي `ar-SA` أرقاماً عربية مشرقية مثل ١٢٣، ويمكنك أن تحدد ذلك صراحة بإضافة `-u-nu-latn` إلى رمز اللغة. وفي الإدخال اقبل الشكلين دائماً كما فعلنا في حقل الجوال، لأن لوحة المفاتيح العربية على الجوال قد تكتب الأرقام المشرقية.
- **التشكيل:** محرك النطق في قارئ الشاشة قد يخطئ في قراءة الكلمة غير المشكولة، كما في فقرة [قارئ الشاشة بالعربية](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%84%D9%85%D9%88%D9%82%D8%B9-%D8%B9%D8%B1%D8%A8%D9%8A/#screen-readers)، فلا تشكل النص كله، وإنما الكلمة الملتبسة التي يغير سوء قراءتها المعنى، وبعد أن تسمعها فعلاً بقارئ الشاشة.
- **الأيقونات المعكوسة:** الأسهم وما يدل على اتجاه تنعكس، والتشغيل والساعة والبحث لا تنعكس، كما في فقرة [الأيقونات التي تنعكس](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%84%D9%85%D9%88%D9%82%D8%B9-%D8%B9%D8%B1%D8%A8%D9%8A/#mirroring). وأضف إلى ذلك الأسهم في لوحة المفاتيح نفسها كما رأيت في التبويبات Tabs.

## تجربة عملية: نافذة وتبويبات ونموذج عربي

سوف نبني صفحة «حسابي» في متجر إلكتروني بالطريقة التي نراها كثيراً، وفيها ثلاثة مكونات: تبويبات Tabs للطلبات والعناوين والإعدادات، ونافذة Dialog لتأكيد حذف عنوان، ونموذج لإضافة عنوان جديد. ثم نعيد بناء المكونات الثلاثة، ونختبر النسختين بأربع طرق: التنقل بزر Tab وطباعة ما يصله التركيز Focus، وشجرة سهولة الوصول Accessibility Tree، وفحص axe، واختبارات Playwright بلوحة المفاتيح فقط. وكل شيء يعمل داخل Container من Docker Image الرسمية لـ Playwright، فلا تحتاج تثبيت Node.js أو متصفح على جهازك.

### ملفات التجربة

```text
lab/
├── package.json            إصدارات الحزم مثبتة
├── package-lock.json
├── walk.mjs                يضغط Tab ويطبع العنصر الذي عليه التركيز
├── tree.mjs                شجرة سهولة الوصول للتبويبات والنموذج والنافذة
├── scan-axe.mjs            فحص axe قبل فتح النافذة وبعده
├── dialog-walk.mjs         حركة التركيز داخل النافذة
├── extras.mjs              تقليل الحركة، وأزرار الاختيار، والأرقام
├── break.sh                نسختان معطلتان من الصفحة الجديدة
├── playwright.config.ts
├── tests/keyboard.spec.ts  اختبارات لوحة المفاتيح
└── site/
    ├── before/index.html   الصفحة كما نراها كثيراً
    └── after/index.html    الصفحة بعد إعادة البناء
```

والإصدارات مثبتة في `package.json`:

```json
{
  "name": "lab-a11y-basics",
  "private": true,
  "type": "module",
  "devDependencies": {
    "@axe-core/playwright": "4.13.0",
    "@playwright/test": "1.64.0",
    "http-server": "14.1.1"
  }
}
```

ونشغل الـ Container ونثبت الحزم داخله، ثم نشغل سيرفر الملفات على المنفذ 8080 داخل الـ Container فقط، فلا ننشر أي منفذ على جهازك:

```bash
cd lab
docker run -d --name lab-a11y-basics --ipc=host -v "$PWD:/src:ro" \
  mcr.microsoft.com/playwright:v1.64.0-noble sleep infinity
docker exec lab-a11y-basics sh -c 'cp -r /src /work && cd /work && npm ci'
docker exec -d -w /work lab-a11y-basics npx http-server site -p 8080 -a 127.0.0.1 -s
```

### الصفحة كما نراها كثيراً

هذا ملف الصفحة الأولى كاملاً، وهي تعمل جيداً بالفأرة، فالتبويبات تتبدل، وأيقونة الحذف تفتح النافذة، والحقل الخاطئ يأخذ حدوداً حمراء:

```html
<!doctype html>
<html lang="ar" dir="rtl">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>حسابي</title>
<style>
  body { font-family: "IBM Plex Sans Arabic", Tahoma, sans-serif; margin: 0; color: #1d1d1f; background: #fff; }
  header { display: flex; gap: 20px; align-items: center; padding: 12px 24px; border-bottom: 1px solid #e5e5e5; }
  header a { color: #1d1d1f; }
  main { max-width: 640px; margin: 24px auto; padding: 0 16px; }
  .tabs { display: flex; gap: 4px; border-bottom: 2px solid #e5e5e5; }
  .tab { padding: 10px 18px; cursor: pointer; color: #1d1d1f; }
  .tab.active { color: #0a7d3c; border-bottom: 3px solid #0a7d3c; }
  .panel { padding: 16px 0; }
  .address { display: flex; justify-content: space-between; align-items: center; padding: 12px; border: 1px solid #e5e5e5; border-radius: 8px; }
  .icon { display: inline-block; width: 18px; height: 18px; cursor: pointer; border: 0; padding: 0; background: none; }
  .overlay { display: none; position: fixed; inset: 0; background: rgba(0, 0, 0, .45); }
  .overlay.open { display: block; }
  .modal { background: #fff; max-width: 380px; margin: 120px auto; padding: 20px; border-radius: 10px; animation: slide .6s ease-out; }
  @keyframes slide { from { transform: translateY(-200px); } to { transform: none; } }
  .close { float: left; cursor: pointer; font-size: 20px; }
  .btn { display: inline-block; padding: 10px 18px; border-radius: 6px; background: #e5e5e5; cursor: pointer; }
  .btn.danger { background: #c0262d; color: #fff; }
  input, select { display: block; width: 100%; box-sizing: border-box; padding: 10px; margin: 8px 0; font: inherit; border: 1px solid #ccc; border-radius: 6px; }
  .invalid { border-color: #d0021b; }
</style>
</head>
<body>
<header>
  <a href="#">الرئيسية</a>
  <a href="#">الأقسام</a>
  <a href="#">السلة</a>
</header>
<main>
  <h1>حسابي</h1>

  <div class="tabs">
    <div class="tab" onclick="show(0)">الطلبات</div>
    <div class="tab active" onclick="show(1)">العناوين</div>
    <div class="tab" onclick="show(2)">الإعدادات</div>
  </div>
  <div class="panel" hidden>لا توجد طلبات حالية.</div>
  <div class="panel">
    <div class="address">
      <span>المنزل، الرياض</span>
      <span>
        <button class="icon" title="تعديل"><svg width="18" height="18" viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16z" fill="none" stroke="#333" stroke-width="2"/></svg></button>
        <span class="icon" onclick="openModal()"><svg width="18" height="18" viewBox="0 0 24 24"><path d="M5 7h14M9 7V4h6v3M7 7l1 13h8l1-13" fill="none" stroke="#c0262d" stroke-width="2"/></svg></span>
      </span>
    </div>
  </div>
  <div class="panel" hidden>الإشعارات مفعلة.</div>

  <h2>إضافة عنوان جديد</h2>
  <form id="addr" onsubmit="return check()">
    <input id="name" placeholder="الاسم الكامل">
    <input id="phone" placeholder="رقم الجوال">
    <select id="city">
      <option value="">المدينة</option>
      <option>الرياض</option>
      <option>جدة</option>
    </select>
    <button>حفظ العنوان</button>
  </form>
</main>

<div class="overlay" id="overlay">
  <div class="modal">
    <span class="close" onclick="closeModal()">×</span>
    <b>حذف العنوان؟</b>
    <p>سوف يحذف عنوان «المنزل، الرياض» من حسابك.</p>
    <div class="btn" onclick="closeModal()">إلغاء</div>
    <div class="btn danger" onclick="closeModal()">حذف</div>
  </div>
</div>

<script>
  function show(i) {
    document.querySelectorAll('.tab').forEach((t, j) => t.classList.toggle('active', i === j));
    document.querySelectorAll('.panel').forEach((p, j) => p.hidden = i !== j);
  }
  function openModal() { document.getElementById('overlay').classList.add('open'); }
  function closeModal() { document.getElementById('overlay').classList.remove('open'); }
  function check() {
    let ok = true;
    for (const [id, valid] of [['name', v => v.trim().length > 1], ['phone', v => /^05\d{8}$/.test(v)], ['city', v => v !== '']]) {
      const el = document.getElementById(id);
      el.classList.toggle('invalid', !valid(el.value));
      ok = ok && valid(el.value);
    }
    return false;
  }
</script>
</body>
</html>
```

والآن سكربت يضغط Tab خمس عشرة مرة من أول الصفحة، ويطبع بعد كل ضغطة العنصر الذي عليه التركيز Focus كما تصفه شجرة سهولة الوصول Accessibility Tree، أي كما يعلنه قارئ الشاشة:

```javascript
// Presses Tab N times from the top of the page and prints, after each press, the focused element
// as the accessibility tree describes it (role and name), which is what a screen reader announces.
//   node walk.mjs http://127.0.0.1:8080/before/ 12
import { chromium } from '@playwright/test';

const [url, n = '12'] = process.argv.slice(2);
const browser = await chromium.launch();
const page = await (await browser.newContext()).newPage();
await page.goto(url);
for (let i = 1; i <= Number(n); i++) {
  await page.keyboard.press('Tab');
  const focused = page.locator(':focus');
  const line = (await focused.count()) ? (await focused.ariaSnapshot()).split('\n')[0].replace(/:$/, '') : '(nothing: focus left the page)';
  console.log(String(i).padStart(2), line);
}
await browser.close();
```

```bash
docker exec -w /work lab-a11y-basics node walk.mjs http://127.0.0.1:8080/before/ 15
```

والمخرج سوف يكون كما يلي:

```text
 1 - link "الرئيسية"
 2 - link "الأقسام"
 3 - link "السلة"
 4 - button "تعديل"
 5 - textbox "الاسم الكامل"
 6 - textbox "رقم الجوال"
 7 - combobox
 8 - button "حفظ العنوان"
 9 (nothing: focus left the page)
10 - link "الرئيسية"
11 - link "الأقسام"
12 - link "السلة"
13 - button "تعديل"
14 - textbox "الاسم الكامل"
15 - textbox "رقم الجوال"
```

في المخرج أعلاه لاحظ التالي:

- التبويبات Tabs غير موجودة في القائمة أصلاً، فمستخدم لوحة المفاتيح لا يستطيع أن يفتح «الطلبات» أو «الإعدادات».
- أيقونة الحذف غير موجودة أيضاً، لأنها `span` عليه `onclick`، أي أن النافذة لا تفتح إلا بالفأرة، والوحيد الذي وصله Tab هو زر التعديل لأنه `button` حقيقي.
- القائمة `combobox` بلا اسم، فقارئ الشاشة يقول «قائمة» فقط، والحقلان أخذا اسمهما من التلميح Placeholder.
- في الضغطة التاسعة خرج التركيز Focus من الصفحة، ثم عادت الدورة من أولها، أي أن هذا كل ما يستطيع مستخدم لوحة المفاتيح الوصول إليه.

ثم نفحص الصفحة بأداة axe مرتين، مرة كما تفتح، ومرة بعد أن نرسل النموذج فارغاً ونفتح النافذة بالفأرة، حتى ترى القواعد حالة الخطأ والنافذة أيضاً:

```javascript
// Runs axe-core on a page twice: as loaded, and again after the dialog is opened and the form is
// submitted empty, so the rules also see the dialog and the error state.
//   node scan-axe.mjs http://127.0.0.1:8080/before/
import { chromium } from '@playwright/test';
import { AxeBuilder } from '@axe-core/playwright';

const url = process.argv[2];
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa', 'best-practice'];
const browser = await chromium.launch();
const page = await (await browser.newContext()).newPage();
await page.goto(url);

const report = async (label) => {
  const r = await new AxeBuilder({ page }).withTags(TAGS).analyze();
  console.log(`--- ${label} (axe-core ${r.testEngine.version})`);
  for (const v of r.violations) console.log(`${v.impact.padEnd(9)} ${v.id.padEnd(24)} nodes=${v.nodes.length}  ${v.help}`);
  console.log(`violations: ${r.violations.length} rules | passes: ${r.passes.length} | incomplete: ${r.incomplete.map(i => i.id).join(', ') || 'none'}`);
};

await report('page as loaded');
await page.locator('#addr button').last().click();          // submit the empty form
await page.locator('.address span').last().locator(':scope > *').last().click();   // open the dialog with the mouse
await report('dialog open, form submitted empty');
await browser.close();
```

```bash
docker exec -w /work lab-a11y-basics node scan-axe.mjs http://127.0.0.1:8080/before/
```

والمخرج سوف يكون كما يلي:

```text
--- page as loaded (axe-core 4.13.0)
critical  select-name              nodes=1  Select element must have an accessible name
violations: 1 rules | passes: 25 | incomplete: none
--- dialog open, form submitted empty (axe-core 4.13.0)
moderate  region                   nodes=1  All page content should be contained by landmarks
critical  select-name              nodes=1  Select element must have an accessible name
violations: 2 rules | passes: 25 | incomplete: none
```

وجد axe قاعدة واحدة حرجة، وهي القائمة بلا اسم، ثم قاعدة `region` عندما فتحت النافذة لأنها خارج أي معلم Landmark. ولم يجد شيئاً عن التبويبات التي لا يصلها Tab، ولا عن أيقونة الحذف، ولا عن النافذة التي لا يغلقها Escape، ولا عن الأخطاء التي لا تظهر إلا كلون. والسبب أن axe يحكم على ما في الكود، والعنصر `div` الذي عليه `onclick` لا يعلن عن نفسه أنه زر حتى تعرف الأداة أنه يجب أن يصله Tab. وهذا ما رأيناه أيضاً في [تجربة صفحة الحجز](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%84%D9%85%D9%88%D9%82%D8%B9-%D8%B9%D8%B1%D8%A8%D9%8A/#lab-run)، ولكنه هنا أوضح، فالأداة وجدت مشكلة واحدة في صفحة لا يستطيع مستخدم لوحة المفاتيح أن ينجز فيها أي مهمة.

وأخيراً شجرة سهولة الوصول Accessibility Tree، أي ما يصل فعلاً إلى قارئ الشاشة:

```javascript
// Prints the accessibility tree (roles, names, states) of the tabs, the open dialog and the form.
// The dialog is opened with a mouse click on purpose, so the "before" page can be inspected too.
//   node tree.mjs http://127.0.0.1:8080/before/
import { chromium } from '@playwright/test';

const browser = await chromium.launch();
const page = await (await browser.newContext()).newPage();
await page.goto(process.argv[2]);
console.log('## main (tabs and address)');
console.log(await page.locator('main').ariaSnapshot());
console.log('## dialog, opened with the mouse');
await page.locator('.address span').last().locator(':scope > *').last().click();
console.log(await page.locator('#del-dialog, .modal').first().ariaSnapshot());
await browser.close();
```

```yaml
## main (tabs and address)
- main:
  - heading "حسابي" [level=1]
  - text: الطلبات العناوين الإعدادات المنزل، الرياض
  - button "تعديل":
    - img
  - img
  - heading "إضافة عنوان جديد" [level=2]
  - textbox "الاسم الكامل"
  - textbox "رقم الجوال"
  - combobox:
    - option "المدينة" [selected]
    - option "الرياض"
    - option "جدة"
  - button "حفظ العنوان"
## dialog, opened with the mouse
- text: × حذف العنوان؟
- paragraph: سوف يحذف عنوان «المنزل، الرياض» من حسابك.
- text: إلغاء حذف
```

التبويبات الثلاثة والعنوان صارت سطراً واحداً من النص `text`، فالمستخدم يسمع «الطلبات العناوين الإعدادات المنزل، الرياض» دون أن يعرف أن هذه تبويبات أو أيها المحدد، وأيقونة الحذف صورة `img` بلا اسم. أما النافذة بعد فتحها بالفأرة فلا يوجد فيها دور `dialog`، وزرا «إلغاء» و«حذف» نص عادي، والصفحة خلفها ما زالت متاحة كاملة.

### إعادة بناء النافذة بعنصر dialog الأصلي

نبدأ بالنافذة، فنستبدل الطبقة المصنوعة من `div` بالعنصر `<dialog>`، والأيقونة بزر حقيقي له اسم يحدد العنوان:

```html
        <button type="button" class="icon-btn" id="del-open" aria-haspopup="dialog" aria-label="حذف عنوان المنزل"><svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24"><path d="M5 7h14M9 7V4h6v3M7 7l1 13h8l1-13" fill="none" stroke="#b3261e" stroke-width="2"/></svg></button>
```

```html
<dialog id="del-dialog" aria-labelledby="del-title" aria-describedby="del-desc">
  <h2 id="del-title">حذف العنوان؟</h2>
  <p id="del-desc">سوف يحذف عنوان «المنزل، الرياض» من حسابك، ولا يمكن التراجع عن ذلك.</p>
  <form method="dialog">
    <button class="btn" value="cancel" autofocus>إلغاء</button>
    <button class="btn danger" value="delete">حذف</button>
  </form>
</dialog>
```

```html
  // Dialog: the native <dialog> with showModal() makes the page behind it inert, closes on Escape
  // and returns focus to the button that opened it.
  const dialog = document.getElementById('del-dialog');
  document.getElementById('del-open').addEventListener('click', () => dialog.showModal());
  dialog.addEventListener('close', () => {
    if (dialog.returnValue === 'delete') document.getElementById('status').textContent = 'تم حذف عنوان المنزل';
  });
```

في الإعداد أعلاه لاحظ التالي:

- اسم الزر «حذف عنوان المنزل» وليس «حذف» فقط، والسبب أن في الصفحة الحقيقية عشرة عناوين، ومستخدم قارئ الشاشة الذي يتنقل بين الأزرار سوف يسمع «حذف، حذف، حذف» دون أن يعرف أي عنوان يحذف، وقد ناقشنا هذا في [سؤال أيقونة الحذف في كل صف](https://arabroot.io/articles/%D8%A7%D8%B3%D8%A6%D9%84%D8%A9-%D9%85%D9%82%D8%A7%D8%A8%D9%84%D8%A9-%D9%85%D8%B7%D9%88%D8%B1-frontend-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%AE%D8%A8%D9%8A%D8%B1/#fe-labels).
- الخاصيتان `aria-labelledby` و`aria-describedby` تعطيان النافذة اسماً هو عنوانها ووصفاً هو نصها، كما سوف ترى في شجرة سهولة الوصول Accessibility Tree.
- الخاصية `autofocus` على زر «إلغاء» تجعله أول ما يصله التركيز Focus، وهذا ما ينصح به دليل APG في الخطوة التي لا يسهل التراجع عنها مثل حذف البيانات: التركيز Focus على الخيار الأقل ضرراً، والسبب أن ضغطة Enter السريعة يجب ألا تحذف شيئاً.
- النموذج `form method="dialog"` يغلق النافذة عند ضغط أي زر، ويضع قيمة الزر في `returnValue` دون JavaScript إضافي.
- رسالة «تم حذف عنوان المنزل» تظهر في عنصر `role="status"` فيعلنها قارئ الشاشة بعد الإغلاق.

والآن نرى حركة التركيز Focus داخل النافذة بسكربت يفتحها بزر Enter، ثم يضغط Tab وShift+Tab وEscape:

```javascript
// Opens the delete dialog from the keyboard, presses Tab and Shift+Tab, then Escape,
// and prints where the focus is after each key.
//   node dialog-walk.mjs http://127.0.0.1:8080/after/
import { chromium } from '@playwright/test';

const browser = await chromium.launch();
const page = await (await browser.newContext()).newPage();
await page.goto(process.argv[2]);
const where = () => page.evaluate(() => {
  const a = document.activeElement;
  if (!a || a === document.body) return 'body (focus left the document)';
  const name = a.getAttribute('aria-label') || a.textContent.trim();
  return `${a.tagName.toLowerCase()} "${name}" ${a.closest('dialog') ? 'inside the dialog' : 'on the page behind'}`;
});
await page.getByRole('button', { name: 'حذف عنوان المنزل' }).focus();
await page.keyboard.press('Enter');
console.log('Enter      ', await where());
for (const key of ['Tab', 'Tab', 'Tab', 'Shift+Tab', 'Shift+Tab', 'Escape']) {
  await page.keyboard.press(key);
  console.log(key.padEnd(11), await where());
}
console.log('dialog open:', await page.locator('dialog').evaluate(d => d.open));
await browser.close();
```

```bash
docker exec -w /work lab-a11y-basics node dialog-walk.mjs http://127.0.0.1:8080/after/
```

والمخرج سوف يكون كما يلي:

```text
Enter       button "إلغاء" inside the dialog
Tab         button "حذف" inside the dialog
Tab         body (focus left the document)
Tab         button "إلغاء" inside the dialog
Shift+Tab   body (focus left the document)
Shift+Tab   button "حذف" inside the dialog
Escape      button "حذف عنوان المنزل" on the page behind
dialog open: false
```

التركيز Focus بقي داخل النافذة ولم يصل إلى الصفحة خلفها، وEscape أغلقها وأعاد التركيز Focus إلى زر «حذف عنوان المنزل» الذي فتحها، دون أي سطر JavaScript لذلك. ولاحظ السطر الثالث، فبعد آخر زر خرج التركيز Focus من الصفحة كلها `body`، ثم عاد إلى أول زر في الضغطة التالية. وهذا يختلف قليلاً عن وصف APG الذي يعيد التركيز Focus إلى أول عنصر مباشرة، ولكنه لا يكسر المعنى، فالمستخدم لا يصل إلى محتوى خلف النافذة. وإذا كان نظام التصميم عندكم يطلب الدوران المباشر فأضف حصر التركيز Focus Trap بنفسك، ولكن لا تضعه بدل `showModal()`.

### إعادة بناء التبويبات Tabs بنمط APG وأسهم عربية

التبويبات هي المكون الوحيد الذي يحتاج ARIA، لأنه لا يوجد عنصر أصلي لها، والكود التالي يتبع نمط APG بالتفعيل التلقائي، أي أن التبويب يفتح بمجرد أن يصله التركيز Focus، وهو ما يوصي به الدليل إذا كان محتوى التبويب جاهزاً دون انتظار:

```html
  <div role="tablist" aria-label="أقسام الحساب">
    <button type="button" role="tab" id="tab-orders" aria-controls="panel-orders" aria-selected="false" tabindex="-1">الطلبات</button>
    <button type="button" role="tab" id="tab-addresses" aria-controls="panel-addresses" aria-selected="true">العناوين</button>
    <button type="button" role="tab" id="tab-settings" aria-controls="panel-settings" aria-selected="false" tabindex="-1">الإعدادات</button>
  </div>
  <div role="tabpanel" id="panel-orders" aria-labelledby="tab-orders" tabindex="0" hidden>لا توجد طلبات حالية.</div>
  <div role="tabpanel" id="panel-addresses" aria-labelledby="tab-addresses" tabindex="0">
    <div class="address">
      <span id="addr-home">المنزل، الرياض</span>
      <span>
        <button type="button" class="icon-btn" aria-label="تعديل عنوان المنزل"><svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24"><path d="M4 20h4L19 9l-4-4L4 16z" fill="none" stroke="#333" stroke-width="2"/></svg></button>
        <button type="button" class="icon-btn" id="del-open" aria-haspopup="dialog" aria-label="حذف عنوان المنزل"><svg aria-hidden="true" focusable="false" width="20" height="20" viewBox="0 0 24 24"><path d="M5 7h14M9 7V4h6v3M7 7l1 13h8l1-13" fill="none" stroke="#b3261e" stroke-width="2"/></svg></button>
      </span>
    </div>
  </div>
  <div role="tabpanel" id="panel-settings" aria-labelledby="tab-settings" tabindex="0" hidden>الإشعارات مفعلة.</div>
```

```html
  // Tabs, following the APG Tabs pattern (automatic activation). APG describes Left/Right for a left-to-right
  // tab list only; here "next" follows the reading direction, so in RTL ArrowLeft moves to the next tab.
  const tablist = document.querySelector('[role="tablist"]');
  const tabs = [...tablist.querySelectorAll('[role="tab"]')];
  function select(tab) {
    for (const t of tabs) {
      const on = t === tab;
      t.setAttribute('aria-selected', String(on));
      t.tabIndex = on ? 0 : -1;
      document.getElementById(t.getAttribute('aria-controls')).hidden = !on;
    }
    tab.focus();
  }
  tabs.forEach(t => t.addEventListener('click', () => select(t)));
  tablist.addEventListener('keydown', (e) => {
    const i = tabs.indexOf(document.activeElement);
    if (i < 0) return;
    const rtl = getComputedStyle(tablist).direction === 'rtl';
    const next = rtl ? 'ArrowLeft' : 'ArrowRight';
    const prev = rtl ? 'ArrowRight' : 'ArrowLeft';
    let j = null;
    if (e.key === next) j = (i + 1) % tabs.length;
    else if (e.key === prev) j = (i - 1 + tabs.length) % tabs.length;
    else if (e.key === 'Home') j = 0;
    else if (e.key === 'End') j = tabs.length - 1;
    if (j === null) return;
    e.preventDefault();
    select(tabs[j]);
  });
```

في الإعداد أعلاه لاحظ التالي:

- كل تبويب عنصر `button` عليه `role="tab"`، فيبقى له سلوك الزر بالفأرة وEnter، ونضيف فقط ما ينقصه.
- التبويب المحدد وحده `tabindex="0"` والباقي `-1`، وهذه طريقة Roving Tabindex، أي أن قائمة التبويبات كلها محطة Tab واحدة، والدالة `select` تنقل القيمة مع التركيز Focus.
- الاتجاه يقرأ من CSS المحسوبة `getComputedStyle(tablist).direction`، فالمكون نفسه يعمل في صفحة عربية وإنجليزية، والسهم الأيسر هو التالي في RTL.
- كل لوحة `tabpanel` مربوطة بتبويبها عبر `aria-labelledby`، وعليها `tabindex="0"` لأنها تبدأ بنص وليس بعنصر تفاعلي، وهذا ما يطلبه النمط حتى يصلها Tab بعد قائمة التبويبات.
- التبويب المحدد يتميز بخط عريض وشريط بسماكة 4 بكسل وليس باللون الأخضر فقط، وهذا من أجل المعيار 1.4.1.

### إعادة بناء النموذج

وأخيراً النموذج، بتسمية Label لكل حقل، والتلميح ورسالة الخطأ مربوطين عبر `aria-describedby`، وملخص للأخطاء، وقبول الأرقام العربية:

```html
  <form id="addr" novalidate aria-labelledby="addr-title">
    <div id="errors" tabindex="-1" hidden>
      <h3>يوجد خطأ في <span id="err-count"></span></h3>
      <ul id="err-list"></ul>
    </div>

    <label for="name">الاسم الكامل</label>
    <input id="name" name="name" autocomplete="name" required aria-describedby="name-err">
    <p id="name-err" class="err" hidden></p>

    <label for="phone">رقم الجوال</label>
    <p id="phone-hint" class="hint">يبدأ بـ 05 ويتكون من 10 أرقام، بالأرقام العربية أو الإنجليزية</p>
    <input id="phone" name="phone" type="tel" inputmode="numeric" autocomplete="tel" dir="ltr" required aria-describedby="phone-hint phone-err">
    <p id="phone-err" class="err" hidden></p>

    <label for="city">المدينة</label>
    <select id="city" name="city" required aria-describedby="city-err">
      <option value="">اختر المدينة</option>
      <option>الرياض</option>
      <option>جدة</option>
    </select>
    <p id="city-err" class="err" hidden></p>

    <fieldset>
      <legend>نوع العنوان</legend>
      <label><input type="radio" name="kind" value="home" checked>منزل</label>
      <label><input type="radio" name="kind" value="work">عمل</label>
      <label><input type="radio" name="kind" value="other">آخر</label>
    </fieldset>

    <button type="submit" class="btn">حفظ العنوان</button>
  </form>
```

```html
  // Form: every error is written in text next to its field, linked with aria-describedby,
  // listed in a summary that gets the focus, and Arabic-Indic digits are accepted.
  const toLatin = s => s.replace(/[٠-٩]/g, d => d.charCodeAt(0) - 0x0660).replace(/[۰-۹]/g, d => d.charCodeAt(0) - 0x06F0);
  const rules = {
    name: v => v.trim().length > 1 || 'اكتب الاسم الكامل',
    phone: v => /^05\d{8}$/.test(toLatin(v.replace(/\s/g, ''))) || 'رقم الجوال يجب أن يبدأ بـ 05 ويتكون من 10 أرقام',
    city: v => v !== '' || 'اختر المدينة',
  };
  document.getElementById('addr').addEventListener('submit', (e) => {
    e.preventDefault();
    const errors = [];
    for (const [id, rule] of Object.entries(rules)) {
      const el = document.getElementById(id), msg = document.getElementById(id + '-err');
      const r = rule(el.value);
      el.setAttribute('aria-invalid', String(r !== true));
      msg.hidden = r === true;
      msg.textContent = r === true ? '' : r;
      if (r !== true) errors.push([id, r]);
    }
    const box = document.getElementById('errors');
    box.hidden = errors.length === 0;
    if (errors.length) {
      document.getElementById('err-count').textContent = ['', 'حقل واحد', 'حقلين'][errors.length] ?? errors.length + ' حقول';
      document.getElementById('err-list').innerHTML = errors.map(([id, m]) => `<li><a href="#${id}">${m}</a></li>`).join('');
      box.focus();
    } else {
      document.getElementById('status').textContent = 'تم حفظ العنوان';
    }
  });
```

في الإعداد أعلاه لاحظ التالي:

- الخاصية `novalidate` تلغي رسائل المتصفح الافتراضية، والسبب أن نصها وشكلها يختلفان بين المتصفحات ولا تظهر كلها معاً، فنكتب رسائلنا بالعربية ونتحكم في مكانها.
- حقل الجوال مربوط بعنصرين `aria-describedby="phone-hint phone-err"`، فيقرأ قارئ الشاشة التلميح ثم الخطأ إن وجد، والعنصر المخفي `hidden` لا يقرأ.
- الدالة `toLatin` تحول الأرقام العربية المشرقية ٠ إلى ٩ والفارسية ۰ إلى ۹ قبل التحقق، فلا يرفض الرقم لأن المستخدم كتبه بلوحة المفاتيح العربية.
- ملخص الأخطاء عليه `tabindex="-1"` وينتقل إليه التركيز Focus بعد الإرسال، فأول ما يسمعه المستخدم «يوجد خطأ في 3 حقول» ثم الروابط إلى الحقول.
- أزرار الاختيار داخل `fieldset` مع `legend`، فيسمع المستخدم «نوع العنوان» مع كل خيار.

### الصفحة بعد إعادة البناء

نشغل السكربتات نفسها على الصفحة الجديدة، ونبدأ بالتنقل بزر Tab:

```bash
docker exec -w /work lab-a11y-basics node walk.mjs http://127.0.0.1:8080/after/ 15
```

```text
 1 - link "انتقل إلى المحتوى"
 2 - link "الرئيسية"
 3 - link "الأقسام"
 4 - link "السلة"
 5 - tab "العناوين" [selected]
 6 - tabpanel "العناوين"
 7 - button "تعديل عنوان المنزل"
 8 - button "حذف عنوان المنزل"
 9 - textbox "الاسم الكامل"
10 - textbox "رقم الجوال"
11 - combobox "المدينة"
12 - radio "منزل" [checked]
13 - button "حفظ العنوان"
14 (nothing: focus left the page)
15 - link "انتقل إلى المحتوى"
```

أول محطة هي رابط التخطي Skip Link، ثم القائمة، ثم التبويب المحدد «العناوين» فقط وليس التبويبات الثلاثة، ثم لوحته، ثم زرا التعديل والحذف بأسماء كاملة، ثم الحقول بأسمائها، ثم زر الاختيار المحدد فقط لأن مجموعة أزرار الاختيار Radio Group محطة واحدة أيضاً، ثم زر الحفظ. ثم فحص axe:

```bash
docker exec -w /work lab-a11y-basics node scan-axe.mjs http://127.0.0.1:8080/after/
```

```text
--- page as loaded (axe-core 4.13.0)
violations: 0 rules | passes: 44 | incomplete: none
--- dialog open, form submitted empty (axe-core 4.13.0)
violations: 0 rules | passes: 17 | incomplete: none
```

صفر مخالفات في الحالتين، ولاحظ أن عدد القواعد الناجحة في الحالة الثانية 17 فقط، والسبب أن النافذة Modal جعلت باقي الصفحة غير متاح، فلم يعد axe يفحص إلا النافذة. ثم شجرة سهولة الوصول Accessibility Tree:

```bash
docker exec -w /work lab-a11y-basics node tree.mjs http://127.0.0.1:8080/after/
```

```yaml
## main (tabs and address)
- main:
  - heading "حسابي" [level=1]
  - tablist "أقسام الحساب":
    - tab "الطلبات"
    - tab "العناوين" [selected]
    - tab "الإعدادات"
  - tabpanel "العناوين":
    - text: المنزل، الرياض
    - button "تعديل عنوان المنزل"
    - button "حذف عنوان المنزل"
  - status
  - heading "إضافة عنوان جديد" [level=2]
  - form "إضافة عنوان جديد":
    - text: الاسم الكامل
    - textbox "الاسم الكامل"
    - text: رقم الجوال
    - paragraph: يبدأ بـ 05 ويتكون من 10 أرقام، بالأرقام العربية أو الإنجليزية
    - textbox "رقم الجوال"
    - text: المدينة
    - combobox "المدينة":
      - option "اختر المدينة" [selected]
      - option "الرياض"
      - option "جدة"
    - group "نوع العنوان":
      - text: نوع العنوان
      - radio "منزل" [checked]
      - text: منزل
      - radio "عمل"
      - text: عمل
      - radio "آخر"
      - text: آخر
    - button "حفظ العنوان"
## dialog, opened with the mouse
- dialog "حذف العنوان؟":
  - heading "حذف العنوان؟" [level=2]
  - paragraph: سوف يحذف عنوان «المنزل، الرياض» من حسابك، ولا يمكن التراجع عن ذلك.
  - button "إلغاء"
  - button "حذف"
```

الآن يسمع المستخدم «أقسام الحساب، قائمة تبويبات» ثم كل تبويب وأيها المحدد، والنموذج له اسم، والحقول لها أسماء، ومجموعة أزرار الاختيار لها اسم، والنافذة دورها `dialog` واسمها عنوانها.

والصورة التالية تبين الصفحتين بعد إرسال النموذج فارغاً، وقد التقطناها من متصفح Chrome على الجهاز بالملفين نفسيهما:

![الصفحة الأولى بعد إرسال النموذج فارغاً: ثلاثة تبويبات نصية والمحدد منها باللون الأخضر، وعنوان المنزل بأيقونتين صغيرتين، وحقول بحدود حمراء دون أي نص يشرح الخطأ](https://arabroot.io/content/images/2026/10/accessibility-basics-page-before.webp)

قبل: الخطأ حدود حمراء فقط، فلا يعرف المستخدم ما المطلوب

![الصفحة بعد إعادة البناء وإرسال النموذج فارغاً: التبويب المحدد بخط عريض وشريط، وزرا التعديل والحذف أكبر، وفي أعلى النموذج ملخص يقول يوجد خطأ في 3 حقول مع روابط، وتحت كل حقل رسالة خطأ بأيقونة تحذير ونص](https://arabroot.io/content/images/2026/10/accessibility-basics-page-after.webp)

بعد: ملخص للأخطاء ينتقل إليه التركيز Focus، ورسالة نصية تحت كل حقل

### اختبارات Playwright بلوحة المفاتيح فقط

السكربتات أعلاه تساعدك على الفهم، أما ما يمنع عودة المشاكل فهو اختبار يعمل في كل طلب دمج. والاختبارات التالية لا تستخدم الفأرة ولا الدالة `focus()` للوصول إلى العناصر، وإنما تضغط Tab حتى تصل إليها كما يفعل المستخدم، وتبحث عن العناصر بدورها واسمها كما شرحنا في [المحددات في صفحة عربية](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-e2e-%D9%85%D8%B9-playwright-%D8%A8%D8%A7%D9%84%D8%AA%D9%81%D8%B5%D9%8A%D9%84/#locators):

```typescript
// Keyboard-only tests for the account page: skip link, tabs (APG pattern, RTL arrows), modal dialog and form errors.
// PAGE_PATH=/before/ runs the same tests on the inaccessible version.
import { test, expect, Page, Locator } from '@playwright/test';

const PAGE = process.env.PAGE_PATH ?? '/after/';

// Presses Tab until the target has the focus, the way a keyboard user reaches it (no .focus() shortcuts).
async function tabTo(page: Page, target: Locator, max = 20) {
  if (await target.count() === 0) throw new Error(`${target} is not in the accessibility tree`);
  for (let i = 0; i < max; i++) {
    await page.keyboard.press('Tab');
    if (await target.evaluate(el => el === document.activeElement).catch(() => false)) return;
  }
  throw new Error(`not reachable with Tab in ${max} presses`);
}

test.beforeEach(async ({ page }) => { await page.goto(PAGE); });

test('skip link is the first Tab stop and moves focus to main', async ({ page }) => {
  await page.keyboard.press('Tab');
  const skip = page.getByRole('link', { name: 'انتقل إلى المحتوى' });
  await expect(skip).toBeFocused();
  await page.keyboard.press('Enter');
  await expect(page.locator('main')).toBeFocused();
});

test('tabs: one Tab stop, arrow keys follow the RTL reading direction', async ({ page }) => {
  const tab = (name: string) => page.getByRole('tab', { name });
  await tabTo(page, tab('العناوين'));
  await page.keyboard.press('ArrowLeft');                   // next tab in RTL
  await expect(tab('الإعدادات')).toBeFocused();
  await expect(tab('الإعدادات')).toHaveAttribute('aria-selected', 'true');
  await expect(page.getByRole('tabpanel', { name: 'الإعدادات' })).toBeVisible();
  await page.keyboard.press('ArrowLeft');                   // wraps to the first tab
  await expect(tab('الطلبات')).toBeFocused();
  await page.keyboard.press('ArrowRight');                  // previous tab in RTL, wraps to the last
  await expect(tab('الإعدادات')).toBeFocused();
  await page.keyboard.press('Home');
  await expect(tab('الطلبات')).toBeFocused();
  await page.keyboard.press('Tab');                         // leaves the tab list to the panel
  await expect(page.getByRole('tabpanel', { name: 'الطلبات' })).toBeFocused();
});

test('dialog: opens from the keyboard, focus stays out of the page behind it, Escape returns focus', async ({ page }) => {
  const opener = page.getByRole('button', { name: 'حذف عنوان المنزل' });
  await tabTo(page, opener);
  await page.keyboard.press('Enter');
  const dialog = page.getByRole('dialog', { name: 'حذف العنوان؟' });
  await expect(dialog).toBeVisible();
  await expect(dialog.getByRole('button', { name: 'إلغاء' })).toBeFocused();
  for (let i = 0; i < 6; i++) {
    await page.keyboard.press('Tab');
    const behind = await page.evaluate(() => document.activeElement?.closest('header, main') ? document.activeElement.textContent?.trim() || document.activeElement.getAttribute('aria-label') : null);
    expect(behind, 'focus reached the page behind the dialog').toBeNull();
  }
  await page.keyboard.press('Escape');
  await expect(dialog).toBeHidden();
  await expect(opener).toBeFocused();
});

test('form: errors are written next to each field, announced with it, and Arabic digits are accepted', async ({ page }) => {
  await tabTo(page, page.getByRole('button', { name: 'حفظ العنوان' }));
  await page.keyboard.press('Enter');
  await expect(page.locator('#errors')).toBeFocused();
  const phone = page.getByRole('textbox', { name: 'رقم الجوال' });
  await expect(phone).toHaveAttribute('aria-invalid', 'true');
  await expect(phone).toHaveAccessibleDescription(/رقم الجوال يجب أن يبدأ بـ 05/);
  await page.getByRole('textbox', { name: 'الاسم الكامل' }).fill('سارة أحمد');
  await phone.fill('٠٥٥١٢٣٤٥٦٧');
  await page.getByRole('combobox', { name: 'المدينة' }).selectOption('جدة');
  await page.getByRole('button', { name: 'حفظ العنوان' }).press('Enter');
  await expect(page.getByRole('status')).toHaveText('تم حفظ العنوان');
  await expect(phone).toHaveAttribute('aria-invalid', 'false');
});
```

```typescript
import { defineConfig } from '@playwright/test';

export default defineConfig({
  testDir: './tests',
  reporter: 'list',
  workers: 1,
  use: { baseURL: 'http://127.0.0.1:8080' },
  webServer: {
    command: 'npx http-server site -p 8080 -a 127.0.0.1 -s',
    url: 'http://127.0.0.1:8080/after/',
    reuseExistingServer: true,
  },
});
```

في الإعداد أعلاه لاحظ التالي:

- الدالة `tabTo` تفشل فوراً برسالة واضحة إذا لم يكن العنصر موجوداً في شجرة سهولة الوصول Accessibility Tree بالدور والاسم المطلوبين، بدل أن تنتظر حتى تنتهي المهلة.
- اختبار النافذة لا يكتفي بأن النافذة ظهرت، وإنما يتحقق بعد كل ضغطة Tab أن التركيز Focus ليس في `header` أو `main` خلفها، ثم أن Escape أعاده إلى الزر الذي فتحها.
- اختبار النموذج يستخدم `toHaveAccessibleDescription`، أي أنه يتحقق مما يقرؤه قارئ الشاشة مع الحقل، وليس من وجود نص أحمر في مكان ما في الصفحة، ثم يكتب رقم الجوال بالأرقام العربية.

نشغلها على الصفحة الجديدة:

```bash
docker exec -w /work lab-a11y-basics npx playwright test
```

```text
Running 4 tests using 1 worker

  ✓  1 tests/keyboard.spec.ts:19:1 › skip link is the first Tab stop and moves focus to main (274ms)
  ✓  2 tests/keyboard.spec.ts:27:1 › tabs: one Tab stop, arrow keys follow the RTL reading direction (531ms)
  ✓  3 tests/keyboard.spec.ts:44:1 › dialog: opens from the keyboard, focus stays out of the page behind it, Escape returns focus (3.0s)
  ✓  4 tests/keyboard.spec.ts:61:1 › form: errors are written next to each field, announced with it, and Arabic digits are accepted (3.8s)

  4 passed (13.3s)
```

ثم على الصفحة الأولى، لنتأكد أن كل اختبار يفشل فعلاً للسبب الصحيح، وقد أبقينا من المخرج سطر الخطأ في كل حالة وحذفنا سطور الكود ومسارات الملفات التي يطبعها Playwright تحته:

```bash
docker exec -w /work -e PAGE_PATH=/before/ lab-a11y-basics npx playwright test --timeout=8000
```

```text
Running 4 tests using 1 worker

  ✘  1 tests/keyboard.spec.ts:19:1 › skip link is the first Tab stop and moves focus to main (7.8s)
  ✘  2 tests/keyboard.spec.ts:27:1 › tabs: one Tab stop, arrow keys follow the RTL reading direction (1.4s)
  ✘  3 tests/keyboard.spec.ts:44:1 › dialog: opens from the keyboard, focus stays out of the page behind it, Escape returns focus (1.7s)
  ✘  4 tests/keyboard.spec.ts:61:1 › form: errors are written next to each field, announced with it, and Arabic digits are accepted (8.4s)

  1) tests/keyboard.spec.ts:19:1 › skip link is the first Tab stop and moves focus to main ─────────

    Error: expect(locator).toBeFocused() failed

    Locator: getByRole('link', { name: 'انتقل إلى المحتوى' })
    Expected: focused
    Timeout: 5000ms
    Error: element(s) not found

  2) tests/keyboard.spec.ts:27:1 › tabs: one Tab stop, arrow keys follow the RTL reading direction ─

    Error: getByRole('tab', { name: 'العناوين' }) is not in the accessibility tree

  3) tests/keyboard.spec.ts:44:1 › dialog: opens from the keyboard, focus stays out of the page behind it, Escape returns focus 

    Error: getByRole('button', { name: 'حذف عنوان المنزل' }) is not in the accessibility tree

  4) tests/keyboard.spec.ts:61:1 › form: errors are written next to each field, announced with it, and Arabic digits are accepted 

    Test timeout of 8000ms exceeded.

    Error: expect(locator).toBeFocused() failed

    Locator: locator('#errors')
    Expected: focused
    Error: element(s) not found

  4 failed
```

كل فشل يقول بالضبط ما الذي ينقص: لا يوجد رابط تخطي، والتبويب «العناوين» ليس تبويباً في شجرة سهولة الوصول Accessibility Tree، وزر «حذف عنوان المنزل» غير موجود، وملخص الأخطاء غير موجود بعد الإرسال.

### نكسرها: أدوار ARIA بلا سلوك، ونافذة لا تحصر التركيز Focus داخلها

الآن نصنع نسختين من الصفحة الجديدة، وفي كل منهما خطأ يقع فيه المطورون كثيراً. في الأولى نحذف معالج الأسهم من التبويبات ونترك الأدوار كما هي، وهذا بالضبط «الدور وعد» الذي لم يوف به. وفي الثانية نفتح النافذة بالدالة `show()` بدل `showModal()`:

```bash
#!/bin/sh
# Builds two broken copies of the "after" page and scans them with axe:
#   bad-aria: the tab roles stay, the arrow-key handler is removed
#   show:     the dialog is opened with show() instead of showModal()
#   docker exec lab-a11y-basics sh /src/break.sh
cd /work
mkdir -p site/bad-aria site/show
sed '/tablist.addEventListener(.keydown./,/^  });$/d' site/after/index.html > site/bad-aria/index.html
sed 's/dialog.showModal()/dialog.show()/' site/after/index.html > site/show/index.html
grep -c "addEventListener('keydown'" site/after/index.html site/bad-aria/index.html
for p in bad-aria show; do node scan-axe.mjs http://127.0.0.1:8080/$p/; done
```

```bash
docker exec lab-a11y-basics sh /src/break.sh
```

والمخرج سوف يكون كما يلي:

```text
site/after/index.html:1
site/bad-aria/index.html:0
--- page as loaded (axe-core 4.13.0)
violations: 0 rules | passes: 44 | incomplete: none
--- dialog open, form submitted empty (axe-core 4.13.0)
violations: 0 rules | passes: 17 | incomplete: none
--- page as loaded (axe-core 4.13.0)
violations: 0 rules | passes: 44 | incomplete: none
--- dialog open, form submitted empty (axe-core 4.13.0)
serious   target-size              nodes=3  All touch targets must be 24px large, or leave sufficient space
violations: 1 rules | passes: 44 | incomplete: none
```

السطران الأولان يؤكدان أن معالج لوحة المفاتيح حذف من النسخة الأولى فقط. والنسخة الأولى نظيفة تماماً عند axe، 44 قاعدة ناجحة ولا مخالفة واحدة، رغم أن التبويبات لم تعد تعمل بلوحة المفاتيح. والسبب أن axe يرى `role="tab"` و`aria-selected` و`aria-controls` بقيم صحيحة، ولا يستطيع أن يعرف أن الأسهم لا تفعل شيئاً. أما النسخة الثانية فوجد فيها axe قاعدة `target-size` فقط، لأن النافذة غير Modal صارت تغطي أزراراً في الصفحة ما زالت متاحة، ولم يذكر شيئاً عن التركيز Focus. والآن اختبارات لوحة المفاتيح على النسختين:

```bash
docker exec -w /work -e PAGE_PATH=/bad-aria/ lab-a11y-basics npx playwright test --timeout=8000 -g "tabs"
docker exec -w /work lab-a11y-basics node dialog-walk.mjs http://127.0.0.1:8080/show/
docker exec -w /work -e PAGE_PATH=/show/ lab-a11y-basics npx playwright test --timeout=8000 -g "dialog"
```

وهذه أهم سطور المخرج، من التشغيلات الثلاثة بالترتيب:

```text
Running 1 test using 1 worker

  ✘  1 tests/keyboard.spec.ts:27:1 › tabs: one Tab stop, arrow keys follow the RTL reading direction (6.4s)

  1) tests/keyboard.spec.ts:27:1 › tabs: one Tab stop, arrow keys follow the RTL reading direction ─

    Error: expect(locator).toBeFocused() failed

    Locator:  getByRole('tab', { name: 'الإعدادات' })
    Expected: focused
    Received: inactive
    Timeout:  5000ms
```

```text
Enter       button "إلغاء" inside the dialog
Tab         button "حذف" inside the dialog
Tab         body (focus left the document)
Tab         a "انتقل إلى المحتوى" on the page behind
Shift+Tab   body (focus left the document)
Shift+Tab   button "حذف" inside the dialog
Escape      button "حذف" inside the dialog
dialog open: true
```

```text
Running 1 test using 1 worker

  ✘  1 tests/keyboard.spec.ts:44:1 › dialog: opens from the keyboard, focus stays out of the page behind it, Escape returns focus (1.4s)

  1) tests/keyboard.spec.ts:44:1 › dialog: opens from the keyboard, focus stays out of the page behind it, Escape returns focus 

    Error: focus reached the page behind the dialog

    expect(received).toBeNull()

    Received: "الرئيسية"
```

اختبار التبويبات وجد ما لم يجده axe: السهم الأيسر لم ينقل التركيز Focus إلى «الإعدادات». وفي النافذة غير Modal وصل التركيز Focus إلى رابط التخطي ثم إلى «الرئيسية» في الصفحة خلفها، وEscape لم يغلقها، فبقيت مفتوحة والتركيز Focus داخلها. والفرق بين النسختين والصفحة الصحيحة كلمة واحدة في الكود في الحالة الثانية، وهذا سبب أن اختبار لوحة المفاتيح يجب أن يكون في خط CI وليس مراجعة يدوية مرة واحدة.

### تقليل الحركة وأزرار الاختيار والأرقام

وأخيراً ثلاثة أمور صغيرة نتحقق منها بسكربت واحد: هل تتوقف حركة النافذة عندما يطلب المستخدم تقليل الحركة، وماذا يفعل Chromium بالأسهم في أزرار الاختيار الأصلية في صفحة RTL، وكيف تكتب الأرقام والتواريخ لعدة رموز لغة عربية في Node.js وفي المتصفح:

```javascript
// Three small checks on the "after" page:
//  1. the dialog animation with and without the reduced-motion preference,
//  2. which radio button the browser selects with ArrowLeft in an RTL group,
//  3. how Chromium and Node.js format numbers and dates for several Arabic locales.
//   node extras.mjs http://127.0.0.1:8080/after/
import { chromium } from '@playwright/test';

const url = process.argv[2];
const browser = await chromium.launch();
console.log('# chromium', browser.version(), '| node', process.version, '| ICU', process.versions.icu, '| CLDR', process.versions.cldr);

for (const reducedMotion of ['no-preference', 'reduce']) {
  const page = await (await browser.newContext({ reducedMotion })).newPage();
  await page.goto(url);
  await page.getByRole('button', { name: 'حذف عنوان المنزل' }).click();
  const anim = await page.locator('dialog').evaluate(d => getComputedStyle(d).animationName + ' ' + getComputedStyle(d).animationDuration);
  console.log(`reducedMotion=${reducedMotion.padEnd(13)} dialog animation: ${anim}`);
}

const page = await (await browser.newContext()).newPage();
await page.goto(url);
await page.getByRole('radio', { name: 'منزل' }).focus();
for (const key of ['ArrowLeft', 'ArrowLeft', 'ArrowRight']) {
  await page.keyboard.press(key);
  console.log(`radio ${key.padEnd(10)} -> checked: ${await page.locator('input[name=kind]:checked').evaluate(r => r.parentElement.textContent)}`);
}

const LOCALES = ['ar', 'ar-SA', 'ar-EG', 'ar-AE', 'ar-MA', 'ar-SA-u-nu-latn'];
const sample = (loc) => `${new Intl.NumberFormat(loc).format(1234567.5)} | ${new Intl.DateTimeFormat(loc, { dateStyle: 'medium', timeZone: 'UTC' }).format(new Date('2026-10-13T00:00:00Z'))}`;
const inBrowser = await page.evaluate(([locs, src]) => { const f = eval(src); return locs.map(l => f(l)); }, [LOCALES, sample.toString()]);
LOCALES.forEach((loc, i) => console.log(`${loc.padEnd(16)} node: ${sample(loc).padEnd(36)} chromium: ${inBrowser[i]}`));
await browser.close();
```

```bash
docker exec -w /work lab-a11y-basics node extras.mjs http://127.0.0.1:8080/after/
```

والمخرج سوف يكون كما يلي:

```text
# chromium 156.0.8078.4 | node v24.21.0 | ICU 78.3 | CLDR 48.0
reducedMotion=no-preference dialog animation: pop 0.25s
reducedMotion=reduce        dialog animation: none 0s
radio ArrowLeft  -> checked: عمل
radio ArrowLeft  -> checked: آخر
radio ArrowRight -> checked: عمل
ar               node: 1,234,567.5 | 13‏/10‏/2026           chromium: 1,234,567.5 | 13‏/10‏/2026
ar-SA            node: ١٬٢٣٤٬٥٦٧٫٥ | ١٣‏/١٠‏/٢٠٢٦           chromium: ١٬٢٣٤٬٥٦٧٫٥ | ١٣‏/١٠‏/٢٠٢٦
ar-EG            node: ١٬٢٣٤٬٥٦٧٫٥ | ١٣‏/١٠‏/٢٠٢٦           chromium: ١٬٢٣٤٬٥٦٧٫٥ | ١٣‏/١٠‏/٢٠٢٦
ar-AE            node: 1,234,567.5 | 13‏/10‏/2026           chromium: 1,234,567.5 | 13‏/10‏/2026
ar-MA            node: 1.234.567,5 | 13‏/10‏/2026           chromium: 1.234.567,5 | 13‏/10‏/2026
ar-SA-u-nu-latn  node: 1,234,567.5 | 13‏/10‏/2026           chromium: 1,234,567.5 | 13‏/10‏/2026
```

في المخرج أعلاه لاحظ التالي:

- مع `reducedMotion=reduce` أصبحت الحركة `none`، وهذا ما يمكنك أن تضيفه كاختبار بالخيار نفسه في Playwright.
- في أزرار الاختيار الأصلية داخل صفحة RTL نقل السهم الأيسر الاختيار من «منزل» إلى «عمل» ثم «آخر»، أي إلى العنصر التالي الذي يقع على اليسار، وهذا يطابق ما اخترناه في التبويبات Tabs.
- الرمز `ar` وحده أعطى أرقاماً إنجليزية، و`ar-SA` و`ar-EG` أعطيا أرقاماً مشرقية، و`ar-MA` أعطى أرقاماً إنجليزية بفاصلة عشرية مختلفة، وNode.js وChromium متطابقان هنا لأن الاثنين يستخدمان بيانات CLDR. وهذه النتائج تتبع إصدار البيانات، فلا تبن واجهتك على افتراض، وحدد نظام الأرقام صراحة كما في السطر الأخير.

### ما الذي لم نشغله

لم نشغل قارئ شاشة حقيقياً في هذه التجربة، فـ NVDA يعمل على Windows مع جلسة سطح مكتب، وOrca يحتاج سطح مكتب Linux مع خدمة سهولة الوصول AT-SPI، والتجربة كلها تعمل في Container دون شاشة. وما رأيته في المخرجات هو شجرة سهولة الوصول Accessibility Tree التي يبنيها Chromium ويقرؤها قارئ الشاشة، وهي تبين الأدوار والأسماء والحالات، ولكنها لا تبين كيف ينطق المحرك العربي النص، ولا هل أعلن قارئ الشاشة رسالة `status` فعلاً. لذلك جرب الصفحة بنفسك مع NVDA أو VoiceOver كما في [طريقة الفحص اليدوي](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%84%D9%85%D9%88%D9%82%D8%B9-%D8%B9%D8%B1%D8%A8%D9%8A/#screen-readers) قبل أن تعتمد مكوناً في نظام التصميم.

### إزالة التجربة

نحذف الـ Container بالاسم، ولم ننشئ أي Volume أو شبكة:

```bash
docker rm -f lab-a11y-basics
# optional, only if nothing else uses it:
# docker image rm mcr.microsoft.com/playwright:v1.64.0-noble
```

## من يملك سهولة الوصول في الفريق؟

رأيت في التجربة أن مشاكل الصفحة الأولى ليست من مطور واحد: لون التبويب المحدد قرار تصميم، ونص الخطأ قرار كتابة، والعنصر `span` قرار برمجة. وتقول W3C في مسودة [توزيع أدوار سهولة الوصول ARRM](https://www.w3.org/WAI/planning/arrm/?ref=arabroot.io)، وهي ما زالت مسودة قيد العمل، إن كل جزء من سهولة الوصول مسؤولية دور مختلف، فالكاتب يكتب نص العنوان، والمصمم يحدد شكله، والمطور يكتبه بالعنصر الصحيح، وإن ترك الموضوع إلى آخر المشروع يجعله يقع على المطور وحده، فيختار ألواناً ويكتب نصوصاً بديلة وعناوين ليست من عمله. وتضع W3C في [دليل التخطيط والإدارة](https://www.w3.org/WAI/planning-and-managing/?ref=arabroot.io) سهولة الوصول كعمل مستمر من البداية إلى ما بعد الإطلاق، وليس فحصاً واحداً قبل النشر.

وعملياً هناك مكانان يغيران النتيجة أكثر من أي شيء آخر:

- **نظام التصميم Design System:** إذا بنيت النافذة والتبويبات وحقل النموذج مرة واحدة بشكل صحيح ومختبر، فكل شاشة جديدة تأخذها جاهزة، وإذا بنيتها خطأ فالخطأ يتكرر في كل شاشة. لذلك ضع اختبارات لوحة المفاتيح مثل التي رأيتها مع كل مكون في مكتبة المكونات نفسها، وضع في رموز التصميم Design Tokens أزواج ألوان مفحوصة التباين بدل ألوان منفردة، كما ناقشنا في [أسئلة أنظمة التصميم](https://arabroot.io/articles/%D8%A7%D8%B3%D8%A6%D9%84%D8%A9-%D9%85%D9%82%D8%A7%D8%A8%D9%84%D8%A9-%D9%85%D8%B7%D9%88%D8%B1-frontend-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%AE%D8%A8%D9%8A%D8%B1/#design-systems).
- **تعريف الإنجاز Definition of Done:** اجعل سهولة الوصول شرطاً لإغلاق المهمة وليس مهمة منفصلة لاحقة، ودور فريق الجودة هنا أن يتحقق منها مع باقي معايير القبول كما شرحنا في [استراتيجية الاختبار](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%88-%D8%B6%D9%85%D8%A7%D9%86-%D8%A7%D9%84%D8%AC%D9%88%D8%AF%D8%A9-qa-%D9%88%D9%83%D9%8A%D9%81-%D9%8A%D8%B9%D9%85%D9%84-%D9%81%D8%B1%D9%8A%D9%82-%D8%A7%D9%84%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1/#strategy-plan).

ومثال على بنود تعريف الإنجاز لأي شاشة جديدة:

1. كل ما يعمل بالفأرة يعمل بلوحة المفاتيح، والتركيز Focus ظاهر دائماً، وترتيبه يتبع ترتيب القراءة من اليمين.
2. كل عنصر تفاعلي له دور واسم صحيحان في شجرة سهولة الوصول Accessibility Tree، والمكونات من نظام التصميم وليست مكتوبة من جديد.
3. كل حقل له تسمية Label ظاهرة، وكل خطأ له نص مربوط بالحقل، والأرقام العربية مقبولة.
4. لا معلومة تعتمد على اللون وحده، وتباين النص والحدود مفحوص.
5. الحركة تتوقف مع تفضيل تقليل الحركة، والفيديو له ترجمة نصية.
6. فحص axe واختبارات لوحة المفاتيح في خط CI ناجحة، ومراجعة سريعة بقارئ الشاشة للمسار الأساسي.

## أخطاء شائعة

- **إضافة `role="button"` على `div` والاكتفاء بذلك:** الدور وحده لا يضيف Tab ولا Enter، فاستخدم `button`.
- **إضافة `aria-label` على كل شيء:** على الرابط أو الزر الذي له نص ظاهر يجعل قارئ الشاشة يقرأ اسماً غير الذي يراه المستخدم، كما في مثال الغطاء من دليل APG.
- **نافذة بالدالة `show()` أو من `div`:** التركيز Focus يهرب إلى الصفحة خلفها، وEscape لا يغلقها.
- **نسيان إعادة التركيز Focus بعد إغلاق نافذة مخصصة:** يجد المستخدم نفسه في أعلى الصفحة ويبدأ من جديد.
- **إصلاح ترتيب العناصر في الواجهة العربية بالخاصية `order` أو `row-reverse`:** الشكل صحيح وترتيب التركيز Focus معكوس.
- **رسالة خطأ لونها أحمر فقط، أو تظهر في Toast يختفي:** اكتبها نصاً بجانب الحقل واربطها به.
- **رفض الأرقام العربية في الحقول:** حولها قبل التحقق بدل أن تطلب من المستخدم تغيير لوحة المفاتيح.
- **الثقة في صفر مخالفات من axe:** رأيت في التجربة صفحة لا يعمل فيها شيء بلوحة المفاتيح ووجد فيها axe قاعدة واحدة، وأخرى معطلة التبويبات بلا أي مخالفة.

## الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

- سهولة الوصول تخدم 1.3 مليار شخص بإعاقة كبيرة، و2.2 مليار على الأقل بضعف في الرؤية، وكل من سوف يكبر في العمر، بحسب منظمة الصحة العالمية.
- ابدأ بعناصر HTML الأصلية، فهي تعطيك الدور والاسم وسلوك لوحة المفاتيح مجاناً، واستخدم ARIA فقط لما لا يوجد له عنصر أصلي، وتذكر أن الدور وعد بسلوك.
- العنصر `dialog` مع `showModal()` يحصر التركيز Focus ويغلق بزر Escape ويعيد التركيز Focus، والفرق بينه وبين `show()` كلمة واحدة.
- دليل APG لا يحدد اتجاه الأسهم في واجهة RTL حتى الآن، فاختر قاعدة واحدة واكتبها في نظام التصميم، وقد اخترنا الاتجاه المرئي الذي يطابق سلوك Chromium مع أزرار الاختيار.
- الخطأ في النموذج نص مربوط بالحقل وليس لوناً، والأرقام العربية مقبولة في الإدخال، وشكلها في العرض تحدده أنت صراحة.
- أداة axe وجدت مشكلة واحدة في صفحة لا تعمل بلوحة المفاتيح، ولم تجد شيئاً في تبويبات معطلة، واختبارات Playwright بلوحة المفاتيح وجدت الحالات كلها.
- سهولة الوصول مسؤولية المصمم والكاتب والمطور والجودة معاً، ومكانها الصحيح نظام التصميم وتعريف الإنجاز.

[![](https://arabroot.io/content/images/2026/10/accessibility-testing-cover-cover.webp)دليلاختبار سهولة الوصول Accessibility لموقع عربيما الذي تجده أدوات فحص سهولة الوصول وما الذي يفوتها، وما الذي يخص المواقع العربية، وكيف تمنع عودة المشاكل في خط CI.](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%B3%D9%87%D9%88%D9%84%D8%A9-%D8%A7%D9%84%D9%88%D8%B5%D9%88%D9%84-accessibility-%D9%84%D9%85%D9%88%D9%82%D8%B9-%D8%B9%D8%B1%D8%A8%D9%8A/)[![](https://arabroot.io/content/images/2026/10/e2e-playwright-cover-cover.webp)دليلاختبارات E2E مع Playwright: من أول اختبار إلى CIكيف تكتب اختبارات متصفح ثابتة لموقع عربي مع Playwright، وتشخص الاختبار المتقلب، وتقسمها على أكثر من جهاز في خط CI.](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1%D8%A7%D8%AA-e2e-%D9%85%D8%B9-playwright-%D8%A8%D8%A7%D9%84%D8%AA%D9%81%D8%B5%D9%8A%D9%84/)[![](https://arabroot.io/content/images/2026/10/mobile-testing-cover-cover.webp)دليلاختبار تطبيقات الجوال مع Appium وMaestroكيف تختار أداة اختبار تطبيقات الجوال، وأين تشغل الاختبارات، ولماذا تنكسر بعض الاختبارات عندما تصبح الواجهة عربية.](https://arabroot.io/articles/%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1-%D8%AA%D8%B7%D8%A8%D9%8A%D9%82%D8%A7%D8%AA-%D8%A7%D9%84%D8%AC%D9%88%D8%A7%D9%84-%D9%85%D8%B9-appium-%D9%88-maestro/)[![](https://arabroot.io/content/images/2026/10/interview-frontend-cover-cover.webp)دليلأسئلة مقابلة مطور Frontend من المبتدئ إلى الخبيربنك أسئلة لمقابلة مطور Frontend، لكل سؤال إجابة المبتدئ وإجابة الخبير وأسئلة متابعة، مع تمارين عملية شغلناها فعلاً.](https://arabroot.io/articles/%D8%A7%D8%B3%D8%A6%D9%84%D8%A9-%D9%85%D9%82%D8%A7%D8%A8%D9%84%D8%A9-%D9%85%D8%B7%D9%88%D8%B1-frontend-%D9%85%D9%86-%D8%A7%D9%84%D9%85%D8%A8%D8%AA%D8%AF%D8%A6-%D8%A7%D9%84%D9%89-%D8%A7%D9%84%D8%AE%D8%A8%D9%8A%D8%B1/)[![](https://arabroot.io/content/images/2026/10/qa-intro-cover-cover.webp)دليلما هو ضمان الجودة QA وكيف يعمل فريق الاختبار؟كيف تفرق بين QA وQC والاختبار، وتصمم حالات الاختبار Test Cases التي تجد الأخطاء قبل أن يجدها عملاؤك.](https://arabroot.io/articles/%D9%85%D8%A7-%D9%87%D9%88-%D8%B6%D9%85%D8%A7%D9%86-%D8%A7%D9%84%D8%AC%D9%88%D8%AF%D8%A9-qa-%D9%88%D9%83%D9%8A%D9%81-%D9%8A%D8%B9%D9%85%D9%84-%D9%81%D8%B1%D9%8A%D9%82-%D8%A7%D9%84%D8%A7%D8%AE%D8%AA%D8%A8%D8%A7%D8%B1/)

## سجل التحديثات

- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على Docker Engine 29.8.0 داخل mcr.microsoft.com/playwright:v1.64.0-noble (Ubuntu 24.04.5، Node.js 24.21.0 مع ICU 78.3 وCLDR 48.0، Chrome for Testing 156.0.8078.4) مع @playwright/test 1.64.0 و@axe-core/playwright 4.13.0 (axe-core 4.13.0) وhttp-server 14.1.1، والتقاط صورتي الصفحتين من Chrome على Windows، ومراجعة صحائف منظمة الصحة العالمية وبلاغي دليل APG رقم 1437 و3464 في 13 أكتوبر 2026\. لم نشغل قارئ شاشة حقيقياً في التجربة.