بناء Combobox سهل الوصول لـ React باستخدام React
٢ يوليو ٢٠٢٦
كومبو بوكس هو أحد أصعب الويدجيتات لبناءها يدويًا: يحتاج إلى role="combobox", aria-expanded, aria-controls, aria-activedescendant, ودعم كامل لوحة المفاتيح، متصل بشكل صحيح. يوضح هذا الدليل بناء كومبو بوكس باستخدام React-aria-components 1.19.0, تخصيص تصميمه باستخدام CSS, وإثبات أنه قابل للوصول باستخدام مجموعة اختبارات jest-axe حقيقية.
ملخص
ستبني كومبو بوكس قابل للتصفية وسهل التنقل باستخدام لوحة المفاتيح باستخدام React-aria-components 1.19.0 (React 19.2, TypeScript 6, Vite 8), تتبع نمط WAI-ARIA لكومبو بوكس دون كتابة أي سمة aria-* يدويًا. ثم ستربطه بنموذج مع التحقق من الحقول المطلوبة, وتتحقق من كل شيء باستخدام jest-axe (بدون انتهاكات, تم التحقق من الحالتين المغلقة والمفتوحة) بالإضافة إلى اختبارات تفاعل لوحة المفاتيح باستخدام @testing-library/user-event. كل كتلة كود في هذه المقالة مُنسوخة من مشروع يتحقق من النوع, ويُبنى, ويمرر مجموعة الاختبارات الخاصة به. خصص حوالي 30 دقيقة.
ما ستتعلمه
- لماذا بناء دلالات كومبو بوكس ARIA يدويًا هو مصدر شائع لأخطاء الوصول, وما هي المتطلبات الفعلية للمعيار
- كيفية تثبيت وتنظيم
React-aria-componentsComboBoxباستخدام API الحقيقي 1.19.0 (ليس الإصدار التجريبي الأحدث الموضح في موقع الوثائق) - كيفية تخصيص تصميم كومبو بوكس بدون هيكل باستخدام CSS عادي مع سمات الحالة
data-* - كيف يعمل التصفية, الحالة الفارغة, وخيارات
menuTriggerفي الواقع — بما في ذلك مفاجأتين ستدهشك إذا قرأت الوثائق فقط - كيفية ربط الكومبو بوكس في نموذج
<form>أصلي معisRequiredوFieldError - كيفية كتابة اختبار وصول آلي باستخدام
jest-axe - كيفية اختبار تفاعل لوحة المفاتيح الكامل (كتابة للتصفية, الأسهم للتركيز, إدخال للاختيار, هروب لإغلاق) باستخدام
@testing-library/user-event
المتطلبات الأساسية
- Node.js 20.19+ أو 22.12+ (متطلب
enginesلـ Vite 8 — Vitest 4 يقبل^20.0.0 || ^22.0.0 || >=24.0.0, لذا أي سلسلة LTS تعمل) ReactوReact-dom19.2.7 (نطاق الأقران لـ React-aria-components يغطي أيضًا React 16.8–19, لذا هذا ليس مخصصًا لـ React-19 فقط)- الخبرة مع مكونات الدوال والهوكات React
- لا حاجة لخبرة سابقة في React Aria
ثبّت التبعية التشغيلية:
npm install React-aria-components@1.19.0
وأداة التطوير/الاختبار المستخدمة في هذا الدليل:
npm install -D vite@8.1.2 @vitejs/plugin-React@6.0.3 vitest@4.1.9 jsdom@29.1.1 \
@testing-library/React@16.3.2 @testing-library/user-event@14.6.1 \
@testing-library/jest-dom@6.9.1 jest-axe@10.0.0 @types/jest-axe@3.5.9 \
TypeScript@6.0.3
الخطوة 1: لماذا لا تكتب الـ divs بنفسك؟
يحدد دليل ممارسات كتابة WAI-ARIA (APG) لنموذج الكومبو بوكس إدخالًا بـ role="combobox" يمتلك aria-controls, aria-expanded, و aria-autocomplete (list لسلوك التصفية أثناء الكتابة في هذا الدليل), بالإضافة إلى قائمة منبثقة يتم تتبع الخيار المُركز باستخدام aria-activedescendant على الإدخال نفسه — لا يغادر تركيز DOM حقًا حقل النص, وكل خيار في القائمة المنبثقة يحتاج إلى aria-selected مضبوطًا بشكل صحيح مع تحرك التركيز الافتراضي.1 إذا أخطأت في أي من هذه — معرف aria-controls قديم, aria-expanded لا يغير حالته, أو معرفات activedescendant لا تطابق معرفات الخيارات المُرسَمة — سيحصل مستخدم قارئ الشاشة على صمت أو معلومات خاطئة, بينما يظل الويدجيت يبدو طبيعيًا تمامًا لمستخدم بصري يختبره بالماوس.
لا تسبب أي من هذه الأخطاء خطأ في الكونسول أو فشل في المراجعة المرئية. تخطي aria-activedescendant و سيعلن قارئ الشاشة عن لا شيء أثناء تمرير المستخدم عبر الاقتراحات. نسيان تحديث aria-expanded, و ستبلغ التقنية المساعدة أن النافذة المنبثقة مغلقة بينما هي مرئية مفتوحة. تظهر فقط عندما يتنقل شخص ما باستخدام لوحة المفاتيح وقارئ الشاشة — وهذا بالضبط هو السبب في ظهور هذا النموذج كثيرًا في تدقيقات الوصول.
React-aria-components تنفذ هذا النموذج لك على مستوى الـ useComboBox وتعرضه كمكونات بدون هيكل وغير مخصصة, لذا تكتب العلامات و CSS, وتدير المكتبة توصيل ARIA, تفاعل لوحة المفاتيح, وإدارة التركيز.12
الخطوة 2: بناء ComboBox الأساسي
أنشئ Combobox.tsx. يلف هذا React-aria-components's ComboBox في مكون صغير, قابل لإعادة الاستخدام, عام:
// src/Combobox.tsx
import {
Button,
ComboBox,
Input,
Label,
ListBox,
Popover,
Text,
FieldError,
type ComboBoxProps as AriaComboBoxProps,
type Key,
type ValidationResult
} from 'React-aria-components';
export interface ComboboxOption {
id: string;
name: string;
}
interface ComboboxProps<T extends ComboboxOption>
extends Omit<AriaComboBoxProps<T>, 'children'> {
label: string;
description?: string;
errorMessage?: string | ((validation: ValidationResult) => string);
items: Iterable<T>;
children: (item: T) => React.ReactNode;
ref?: React.Ref<HTMLDivElement>;
}
// All ARIA roles, states (aria-expanded, aria-activedescendant,
// aria-controls), keyboard handling, and focus management come from
// React-aria-components. This wrapper only supplies markup + styling hooks.
// React 19 lets function components accept `ref` as a plain prop, so no
// forwardRef wrapper is needed here (see Troubleshooting).
export function Combobox<T extends ComboboxOption>({
label,
description,
errorMessage,
items,
children,
ref,
...props
}: ComboboxProps<T>) {
return (
<ComboBox {...props} ref={ref} defaultItems={items} className="combobox">
<Label className="combobox-label">{label}</Label>
<div className="combobox-field">
<Input className="combobox-input" />
<Button className="combobox-button" aria-label="Show suggestions">
▼
</Button>
</div>
{description && (
<Text slot="description" className="combobox-description">
{description}
</Text>
)}
<FieldError className="combobox-error">{errorMessage}</FieldError>
<Popover className="combobox-popover">
<ListBox
className="combobox-listbox"
renderEmptyState={() => (
<span className="combobox-empty">No matches found</span>
)}
>
{children}
</ListBox>
</Popover>
</ComboBox>
);
}
export { ListBoxItem } from 'React-aria-components';
export type { Key };
شيئان يستحقان الإشارة هنا, لأنهما يربكان من يقرأون الوثائق التسويقية أولًا بدلاً من الحزمة المثبتة: ComboBox في الإصدار 1.19.0 لا يقبل مباشرة label أو placeholder كخصائص — يتم التعامل معهما بواسطة العنصر الفرعي <Label> و <Input placeholder="…"> — ولا يوجد تصدير ComboBoxItem. العناصر هي ListBoxItem عادية, مثل ListBox المستقل. يمكنك التأكد من ذلك بنفسك بقراءة node_modules/React-aria-components/dist/types/exports/ComboBox.d.ts بعد التثبيت — موقع الوثائق يعرض حاليًا شكلًا أحدث وغير منشور (ComboBoxItem, غلاف <Group>, label/placeholder كخصائص مباشرة) لا يطابق ما يصدره npm كإصدار 1.19.0.2
الخطوة 3: استخدامه مع قائمة حقيقية
// src/App.tsx
import { useState } from 'React';
import { Combobox, ListBoxItem, type ComboboxOption, type Key } from './Combobox';
interface Framework extends ComboboxOption {
name: string;
}
const frameworks: Framework[] = [
{ id: 'React', name: 'React' },
{ id: 'vue', name: 'Vue' },
{ id: 'svelte', name: 'Svelte' },
{ id: 'solid', name: 'Solid' },
{ id: 'angular', name: 'Angular' },
{ id: 'qwik', name: 'Qwik' },
{ id: 'astro', name: 'Astro' }
];
export function App() {
// Use React-aria-components' own `Key` type, not React's built-in one --
// they are structurally different and mixing them breaks the typecheck
// on `selectedKey` (see Troubleshooting).
const [selected, setSelected] = useState<Key | null>(null);
return (
<form aria-label="Framework picker">
<Combobox
label="Favorite framework"
description="Type to filter, then pick one with the keyboard or mouse."
items={frameworks}
name="framework"
selectedKey={selected}
onSelectionChange={setSelected}
isRequired
errorMessage="Please choose a framework."
allowsEmptyCollection
>
{(item) => <ListBoxItem id={item.id}>{item.name}</ListBoxItem>}
</Combobox>
<p data-testid="selection">Selected: {selected ?? 'none'}</p>
</form>
);
}
defaultItems (المُضبط داخل Combobox.tsx) يجعل قائمة العناصر غير مُسيطر عليها — React-aria-components يصفّيها داخليًا أثناء كتابة المستخدم, باستخدام مطابقة "تحتوي على" غير حساسة لحالة الأحرف افتراضيًا. إذا احتجت إلى منطق تصفية مخصص (مطابقة غامضة, تصفية حسب حقل آخر غير النص المعروض), امرر دالة defaultFilter إلى ComboBox.
الخطوة 4: تخصيص التصميم — إنه بدون هيكل, وليس بدون تصميم إلى الأبد
React-aria-components يعرض عناصر DOM عادية مع سمات data-* تعكس الحالة, لذا تخصص تصميمها باستخدام CSS عادي ولا خصائص تخصيص وقت التشغيل:
/* Combobox.css */
.combobox-field {
display: flex;
align-items: center;
border: 1px solid #6b7280;
border-radius: 6px;
}
.combobox-input {
flex: 1;
padding: 0.5rem 0.75rem;
border: none;
outline: none;
}
/* [data-focused] is set by the library while the input has focus */
.combobox-input[data-focused] {
outline: 2px solid #2563eb;
outline-offset: -1px;
}
.combobox-popover {
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.combobox-listbox {
max-height: 240px;
overflow-y: auto;
padding: 0.25rem;
}
/* [data-focused] here means "virtually focused via aria-activedescendant" */
[data-focused].React-aria-ListBoxItem {
background: #eff6ff;
}
[data-selected].React-aria-ListBoxItem {
font-weight: 600;
}
.combobox-error {
color: #dc2626;
font-size: 0.875rem;
}
كل عنصر تفاعلي يعرض محددات الحالة المتوقعة: [data-open] و [data-invalid] على جذر ComboBox, [data-disabled] و [data-hovered] على العناصر الفردية, وهكذا — تحقق من شجرة DOM المُرسَمة في أدوات المطورين في متصفحك لرؤية قائمة السمات الكاملة لأي مكون.
الخطوة 5: سلوكان سيصدمانك
هذه ليست أخطاء — إنها بالضبط ما يطلبه المعيار — لكنها لا تطابق ما يتوقعه معظم الناس لأول مرة:
الكتابة لا تفتح النافذة المنبثقة تلقائيًا لمجموعة نتائج فارغة. افتراضيًا, ComboBox يخفي النافذة المنبثقة تمامًا عندما تكون المجموعة المُصفاة فارغة, لذا رسالتك renderEmptyState لن تظهر أبدًا. مرر allowsEmptyCollection (كما في الخطوة 3) إذا كنت تريد حالة "لا تطابقات" مرئية بدلًا من إغلاق النافذة المنبثقة بصمت.
النقر أو التركيز على الحقل وحده لا يفتح القائمة المنبثقة. القيمة الافتراضية لـ menuTrigger هي 'input'، مما يعني أن القائمة تفتح فقط عندما يقوم المستخدم بتعديل النص — وليس بمجرد النقر أو التركيز. إذا كنت تريد فتح القائمة عند التركيز (وهو أمر شائع في صناديق الاختيار "تصفح جميع الخيارات")، فقم بضبط menuTrigger="focus"؛ أما إذا كنت تريد فتحها فقط عبر زر التشغيل أو مفاتيح الأسهم، فاستخدم menuTrigger="manual".
الكتابة لا تبرز المطابقة الأولى تلقائيًا. هذه تفصيلة في المواصفات وليست خللاً في المكتبة: تحدد مواصفات WAI-ARIA APG أربعة سلوكيات للإكمال التلقائي، وتنفذ React-aria-components "الإكمال التلقائي للقائمة مع الاختيار اليدوي" — حيث يؤدي التصفية إلى تضييق القائمة، ولكن لا يتم اختيار أي شيء تلقائيًا حتى يضغط المستخدم على ArrowDown (أو ينقر على خيار).1 لذا فإن user.keyboard('astro{Enter}') لا تفعل شيئًا في الاختبار؛ ستحتاج إلى user.keyboard('astro{ArrowDown}{Enter}'). هذا الأمر يهم المستخدمين الحقيقيين أيضًا: لا تفترض أن الضغط على Enter وحده سيؤكد النتيجة الأولى المصفاة.
الخطوة 6: التكامل مع النماذج
يتصرف ComboBox بالفعل كحقل نموذج بمجرد إعطائه name. اضبط formValue="key" (الافتراضي) لإرسال الـ id الخاص بالعنصر المختار، أو formValue="text" لإرسال النص المرئي بدلاً من ذلك. وبالاقتران مع isRequired و errorMessage، يبدأ تفعيل التحقق الأصلي لـ HTML5 عند الإرسال:
<Combobox
label="Favorite framework"
items={frameworks}
name="framework"
isRequired
errorMessage={({ validationDetails }) =>
validationDetails.valueMissing ? 'Please choose a framework.' : 'Invalid selection.'
}
>
{(item) => <ListBoxItem id={item.id}>{item.name}</ListBoxItem>}
</Combobox>
في الكواليس، يقوم ComboBox بإنشاء حقل مخفي <input type="hidden" name="framework"> يعكس المفتاح المختار، لذا فإن أي <form onSubmit> عادي أو قراءة لـ FormData سيلتقطه تمامًا مثل حقل <select> الأصلي — لا حاجة لمتحكم أو محول لاستخدامه مع fetch أو Server Action.
الخطوة 7: اختبار إمكانية الوصول الآلي باستخدام jest-axe
إعداد بيئة الاختبار:
// src/test-setup.ts
import '@testing-library/jest-dom/vitest';
import { expect } from 'vitest';
import { toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import React from '@vitejs/plugin-React';
export default defineConfig({
plugins: [React()],
test: {
environment: 'jsdom',
setupFiles: ['./src/test-setup.ts'],
globals: true
}
});
الآن تحقق من وجود أي انتهاكات في كل من الحالتين المغلقة والمفتوحة — فمشاكل إمكانية الوصول في صناديق الاختيار غالبًا ما تكمن تحديدًا في القائمة المنبثقة، لذا فإن اختبار الحالة المغلقة فقط سيجعلك تغفل عنها:
// src/Combobox.test.tsx
import { describe, it, expect } from 'vitest';
import { render, screen, within } from '@testing-library/React';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { App } from './App';
describe('Combobox accessibility', () => {
it('has no axe violations when closed', async () => {
const { container } = render(<App />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('has no axe violations when open with results', async () => {
const user = userEvent.setup();
const { container } = render(<App />);
await user.click(screen.getByRole('combobox', { name: /favorite framework/i }));
await user.keyboard('s');
expect(await screen.findByRole('listbox')).toBeInTheDocument();
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
كلا الفحصين ينجحان بدون أي انتهاكات ضد المكون الذي تم بناؤه أعلاه — لا حاجة لربط aria-* يدويًا، لأن المكتبة تتولى ذلك.
الخطوة 8: اختبار التفاعل عبر لوحة المفاتيح
مستخدمو قارئات الشاشة ومستخدمو لوحة المفاتيح فقط لا يلمسون الماوس أبدًا، لذا فإن الاختبارات الأكثر أهمية هي التي تحاكي ذلك بالضبط:
describe('Combobox keyboard interaction', () => {
it('filters options as the user types', async () => {
const user = userEvent.setup();
render(<App />);
const input = screen.getByRole('combobox', { name: /favorite framework/i });
await user.type(input, 'sv');
const listbox = await screen.findByRole('listbox');
const options = within(listbox).getAllByRole('option');
expect(options).toHaveLength(1);
expect(options[0]).toHaveTextContent('Svelte');
});
it('selects an option with ArrowDown + Enter and updates external state', async () => {
const user = userEvent.setup();
render(<App />);
const input = screen.getByRole('combobox', { name: /favorite framework/i });
await user.click(input);
await user.keyboard('{ArrowDown}{ArrowDown}{Enter}');
expect(screen.getByTestId('selection')).toHaveTextContent('vue');
expect(input).toHaveValue('Vue');
});
it('closes the popover on Escape without changing the selection', async () => {
const user = userEvent.setup();
render(<App />);
const input = screen.getByRole('combobox', { name: /favorite framework/i });
await user.type(input, 'React');
expect(await screen.findByRole('listbox')).toBeInTheDocument();
await user.keyboard('{Escape}');
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
expect(screen.getByTestId('selection')).toHaveTextContent('none');
});
});
يقوم {ArrowDown}{ArrowDown} بتحريك التركيز الافتراضي (عبر aria-activedescendant) متجاوزًا React إلى Vue، العنصر الثاني، ويقوم {Enter} بتأكيده — لا يغادر تركيز DOM حقل إدخال النص طوال الوقت، وهو بالضبط ما تتطلبه مواصفات APG وما يعتمد عليه قارئ الشاشة الحقيقي لمواصلة الإعلان بشكل صحيح.
التحقق
قم بتشغيل مجموعة الاختبارات الكاملة:
npx vitest run
الناتج المتوقع للمشروع الذي تم بناؤه في هذا الدليل (الكود الموضح أعلاه يغطي الاختبارات الخمسة حتى الخطوة 8 — زوج إمكانية الوصول من الخطوة 7 بالإضافة إلى اختبارات التفاعل الثلاثة عبر لوحة المفاتيح من الخطوة 8):
✓ src/Combobox.test.tsx (5 tests) 388ms
Test Files 1 passed (1)
Tests 5 passed (5)
وتأكد من أن المكون يتم بناؤه فعليًا للإنتاج:
npx vite build
✓ built in 103ms
dist/assets/index-DaLH_xDj.js 382.97 kB │ gzip: 116.72 kB
الأخطاء الشائعة
TS2322: Type 'Key | null' is not assignable to type 'Key | null | undefined'. لقد قمت باستيراد Key من 'React' بدلاً من 'React-aria-components'. نوع Key الخاص بـ React ونوع Key المعاد تصديره من React-aria-components مختلفان هيكليًا، ولن يقوم TypeScript بتوحيدهما. الحل: import { type Key } from 'React-aria-components' واستخدمه في كل مكان تخزن فيه مفتاحًا مختارًا.
ComboBoxItem is not exported from 'React-aria-components'. لقد قمت بنسخ مثال من موقع توثيق React-aria.adobe.com، والذي يعرض حاليًا معاينة لإعادة تصميم API القادمة. تستخدم حزمة 1.19.0 على npm عنصر ListBoxItem للعناصر، وليس ComboBoxItem. تحقق دائمًا من كود المثال مقابل ملفات .d.ts الفعلية في مجلد node_modules المثبت لديك عندما يكون موقع التوثيق وإصدار package.json غير متزامنين.
رسالة "لم يتم العثور على مطابقات" لا تظهر أبدًا. أنت تقوم بالتصفية إلى صفر نتائج بدون استخدام allowsEmptyCollection في <ComboBox>. بدونه، لا تفتح القائمة المنبثقة ببساطة عندما تكون المجموعة فارغة، لذا لا يمكن الوصول إلى محتوى renderEmptyState الخاص بك.
user.click(input) لا يفتح القائمة المنبثقة في الاختبار. هذه ليست مشكلة في مكتبة الاختبار — بل هو السلوك الافتراضي الحقيقي والمتوافق مع المواصفات. القيمة الافتراضية لـ menuTrigger هي 'input' (تفتح عند تعديل النص)، وليس 'focus' أو 'manual'. إما أن تكتب حرفًا في اختبارك، أو تضبط menuTrigger="focus" في المكون إذا كان هذا هو تجربة المستخدم التي تريدها فعليًا.
الضغط على Enter مباشرة بعد الكتابة لا يفعل شيئًا. لا يتم إبراز أي شيء تلقائيًا بعد التصفية (انظر الخطوة 5). أرسل {ArrowDown} أولاً لتحريك التركيز الافتراضي إلى النتيجة الأولى قبل الضغط على Enter.
الـ ref الخاص بك لـ <Combobox> يعود بـ null، أو ترى تحذير Warning: Function components cannot be given refs. هذه مشكلة تتعلق بإصدار React، وليست مشكلة في React-aria-components. منذ React 19 (المستخدم في هذا الدليل، والمؤكد باختبار مباشر في الـ sandbox)، يمكن للمكونات الوظيفية قبول ref كخاصية عادية، لذا فإن Combobox.tsx أعلاه لا يحتاج إلى forwardRef على الإطلاق — تم التحقق من عمله، والتحذير لا يظهر في الإصدار 19.2.7. إذا كان مشروعك لا يزال يستخدم React 18 أو أقدم، فأنت بحاجة إلى تغليف المكون في forwardRef (تغيير ref-as-prop في React 19 لم يتم نقله للإصدارات الأقدم)؛ تخطي ذلك هناك يؤدي إلى ظهور هذا التحذير بالضبط ويترك الـ ref فارغًا.
الخطوات التالية ومزيد من القراءة
إذا كنت تقوم بربط هذا الـ combobox بنموذج أكبر، فراجع دليل React Hook Form + Zod أو دليل TanStack Form + Zod للتعرف على أنماط التحقق المعتمدة على المخطط (schema-driven) والتي تتوافق مع مكون بشكل النموذج الأصلي مثل هذا. بالنسبة لإعداد الاختبار نفسه، يغطي دليل حدود تغطية Vitest كيفية فرض حد أدنى للتغطية في CI بمجرد حصولك على مجموعة اختبارات مثل تلك التي تم بناؤها هنا.
Footnotes
-
Combobox Pattern — ARIA Authoring Practices Guide, W3C WAI, fetched 2026-07-02. ↩ ↩2 ↩3
-
React-aria-components on npm — version 1.19.0, published 2026-06-18; peer dependencies and API surface verified against the installed package's
.d.tsfiles. ↩ ↩2 ↩3 -
@React-aria/test-utils on npm — version 1.0.0-rc.0, published 2026-05-28 (still the
latestdist-tag as of this writing). ↩