المكونات المركبة (Compound Components)
مكون واحد و خيارات كثيرة
لنبدأ بمكون بسيط يعرض مقدار التقدم. سرعان ما نحتاج إلى عنوان أو قيمة ظاهرة أو أيقونة أو ترتيب مختلف للأجزاء. و قد نحاول تلبية كل طلب بإضافة prop جديدة:
- إظهار عنوان لشريط التقدم.
- إخفاء العنوان في بعض الحالات.
- عرض القيمة الحالية كنسبة مئوية.
- إضافة أيقونات.
- تغيير ترتيب بعض العناصر.
- تخصيص أجزاء معينة من واجهة المستخدم.
قد نحاول التعامل مع هذه الحالات عن طريق إضافة المزيد من props إلى المكون:
<ProgressBar
label="Uploading..."
showValue
showIcon
icon={<UploadIcon />}
valuePosition="right"
/>ينجح هذا الأسلوب ما دامت الخيارات قليلة. لكن كل تغيير جديد يوسع واجهة ProgressBar و يضيف إليه شروطا أخرى. نريد بدلا من ذلك أن نمنح مستخدم المكون حرية اختيار أجزائه و ترتيبها.
مكونات صغيرة تعمل معا
هذا هو دور نمط Compound Components. نقسم الواجهة إلى مكونات صغيرة تعمل معا و تشترك في الحالة نفسها. لا ينشئ المكون الأب بنية ثابتة، بل يترك لمن يستخدمه اختيار الأجزاء و ترتيبها.
سنطبق الفكرة على ProgressBar يتكون من أربعة أجزاء:
ProgressBarTrackللمسار الكامل.ProgressBarIndicatorللمقدار المنجز.ProgressBarLabelلاسم العملية أو وصفها.ProgressBarValueللقيمة الحالية.

المنطق المشترك داخل Hook
تحتاج الأجزاء كلها إلى النسبة الحالية و بعض خصائص الوصول. سنحسب هذه البيانات مرة واحدة داخل Hook مخصص ثم نشارك النتيجة مع الأجزاء.
// path: src/components/progress-bar/use-progress.ts
import { useProgressBar } from "@react-aria/progress";
import type { AriaProgressBarProps } from "@react-aria/progress";
import type { HTMLAttributes } from "react";
interface Props extends HTMLAttributes<HTMLElement> {}
export type UseProgressProps = Props & AriaProgressBarProps;
export const useProgress = (props: UseProgressProps) => {
const {
value = 0,
id,
label,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
valueLabel,
minValue = 0,
maxValue = 100,
formatOptions = {
style: "percent",
},
isIndeterminate,
...otherProps
} = props;
const { progressBarProps, labelProps } = useProgressBar({
id,
label,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
value,
valueLabel,
minValue,
maxValue,
formatOptions,
isIndeterminate,
});
const percentage = Math.min(
Math.max(((value - minValue) / (maxValue - minValue)) * 100, 0),
100,
);
return {
value,
percentage,
isIndeterminate,
progressBarProps,
labelProps,
otherProps,
};
};
export type UseProgressReturn = ReturnType<typeof useProgress>;يستخدم useProgress الخطاف useProgressBar من React Aria ليجهز خصائص الوصول للعنصر الرئيسي و للتسمية:
const { progressBarProps, labelProps } = useProgressBar({
id,
label,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledby,
value,
valueLabel,
minValue,
maxValue,
formatOptions,
isIndeterminate,
});يعيد الخطاف كائنين نحتاج إليهما:
progressBarPropsللعنصر الرئيسي.labelPropsلعنصر التسمية.
يعيد useProgress هذين الكائنين مع النسبة و الخصائص الأخرى. سننشرها مباشرة في هذا الدرس. و في درس Prop Getters سنرى لماذا يصبح الدمج المباشر محدودا عندما يضيف المستخدم خصائصه الخاصة.
عندما نعرض تسمية مرئية نمرر نصها إلى label في المكون الأب و نعرضها داخل ProgressBarLabel. بذلك ينشئ React Aria معرفا للتسمية و يربطه بالشريط. أما إذا حذفنا التسمية المرئية فنمرر aria-label أو aria-labelledby حتى يبقى للشريط اسم تقرؤه أدوات المساعدة. يجب أن تصل هذه الخصائص إلى useProgressBar كي يجهز الربط الصحيح.
مشاركة البيانات عبر Context
تحتاج أجزاء مثل ProgressBarLabel و ProgressBarValue إلى البيانات التي أعادها useProgress. بدلا من تمريرها عبر كل طبقة، سنضعها في Context يقرأ منه كل جزء.
// path: src/components/progress-bar/progress-context.tsx
import { createContext, useContext } from "react";
import type { UseProgressReturn } from "./use-progress";
export const ProgressContext = createContext<UseProgressReturn | undefined>(
undefined,
);
export const useProgressContext = () => {
const context = useContext(ProgressContext);
if (!context) {
throw new Error("useProgressContext must be used within a ProgressContext");
}
return context;
};يحمل ProgressContext نتيجة useProgress. أما useProgressContext فيعيدها للأجزاء الداخلية و يعرض خطأ واضحا إذا استعملنا أحد هذه الأجزاء خارج المكون الأب.
دور المكون الرئيسي ProgressBar
أصبح بإمكاننا الآن بناء المكون الأب:
// path: src/components/progress-bar/progress.tsx
interface ProgressBarProps extends UseProgressProps {}
export function ProgressBar({
children,
className,
...props
}: ProgressBarProps) {
const state = useProgress(props);
return (
<ProgressContext value={state}>
<div
{...state.otherProps}
{...state.progressBarProps}
data-indeterminate={state.isIndeterminate || undefined}
className={className}
>
{children}
</div>
</ProgressContext>
);
}يحسب ProgressBar الحالة المشتركة و يضعها في Context لكنه لا ينشئ الأجزاء بنفسه. يستقبلها عبر children و يترك ترتيبها لمن يستخدم المكون. تبدو العلاقة بينها هكذا:
ProgressBar
├── ProgressBarLabel
├── ProgressBarValue
└── ProgressBarTrack
└── ProgressBarIndicator
مكونات ProgressBar الداخلية
لا يحتاج المسار و المؤشر إلا إلى بنية العنصر و نسبة التقدم:
interface ProgressBarTrackProps extends React.HTMLAttributes<HTMLDivElement> {}
export function ProgressBarTrack({
children,
className,
...props
}: ProgressBarTrackProps) {
return (
<div className={className} {...props}>
{children}
</div>
);
}
export function ProgressBarIndicator({
className,
style,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
const { percentage } = useProgressContext();
return (
<div
style={{ ...style, width: `${percentage.toFixed(0)}%` }}
className={className}
{...props}
/>
);
}يقرأ المؤشر النسبة من Context و يحولها إلى عرض. دمجنا style الذي يمرره المستخدم قبل width حتى تظل النسبة هي التي تحدد طول المؤشر.
أما التسمية فتحتاج إلى خصائص الوصول التي جهزها useProgressBar:
interface ProgressBarLabelProps
extends React.HTMLAttributes<HTMLSpanElement> {}
export function ProgressBarLabel({
children,
className,
...props
}: ProgressBarLabelProps) {
const { labelProps } = useProgressContext();
return (
<span {...props} {...labelProps} className={className}>
{children}
</span>
);
}ينشر ProgressBarLabel خصائص الوصول على عنصر التسمية. و يقرأ ProgressBarValue النسبة من Context:
interface ProgressBarValueProps
extends React.HTMLAttributes<HTMLSpanElement> {}
export function ProgressBarValue({
className,
...props
}: ProgressBarValueProps) {
const { percentage } = useProgressContext();
return (
<span className={className} {...props}>
{percentage.toFixed(0)}%
</span>
);
}تركيب ProgressBar
نختار الآن الأجزاء التي نحتاج إليها و نرتبها داخل ProgressBar:
function App() {
const [progressValue] = useState(18);
return (
<ProgressBar
value={progressValue}
minValue={0}
maxValue={100}
label="Progress With Label"
className="..."
>
<ProgressBarLabel className="...">Progress With Label</ProgressBarLabel>
<ProgressBarValue className="..." />
<ProgressBarTrack className="...">
<ProgressBarIndicator className="..." />
</ProgressBarTrack>
</ProgressBar>
);
}لا يفرض ProgressBar بنية واحدة. يمكننا إضافة التسمية:
<ProgressBar label="Uploading...">
<ProgressBarLabel>Uploading...</ProgressBarLabel>
<ProgressBarTrack>
<ProgressBarIndicator />
</ProgressBarTrack>
</ProgressBar>أو حذفها إذا لم نحتج إليها:
<ProgressBar aria-label="Uploading files">
<ProgressBarTrack>
<ProgressBarIndicator />
</ProgressBarTrack>
</ProgressBar>و يمكننا عرض القيمة من دون إضافة خيار جديد إلى المكون الأب:
<ProgressBar aria-label="Uploading files">
<ProgressBarValue />
<ProgressBarTrack>
<ProgressBarIndicator />
</ProgressBarTrack>
</ProgressBar>كما نستطيع تخصيص محتوى التسمية:
<ProgressBar label="Uploading files...">
<ProgressBarLabel>
<span>Uploading files...</span>
</ProgressBarLabel>
<ProgressBarTrack>
<ProgressBarIndicator />
</ProgressBarTrack>
</ProgressBar>
ما الذي وفره هذا النمط؟
يعالج نمط Compound Components مشكلة المكون الذي تكبر واجهته مع كل خيار جديد. يقسمه إلى أجزاء تشترك في الحالة عبر Context ثم يترك لمستخدمه اختيار هذه الأجزاء و ترتيبها.
في الدرس التالي ستطبق الفكرة ببناء مكون تقييم من أجزاء متعاونة.