MASTRILOالدورة/المكونات المركبة (Compound Components)
MASTRILO

المكونات المركبة (Compound Components)

مكون واحد و خيارات كثيرة

لنبدأ بمكون بسيط يعرض مقدار التقدم. سرعان ما نحتاج إلى عنوان أو قيمة ظاهرة أو أيقونة أو ترتيب مختلف للأجزاء. و قد نحاول تلبية كل طلب بإضافة prop جديدة:

  • إظهار عنوان لشريط التقدم.
  • إخفاء العنوان في بعض الحالات.
  • عرض القيمة الحالية كنسبة مئوية.
  • إضافة أيقونات.
  • تغيير ترتيب بعض العناصر.
  • تخصيص أجزاء معينة من واجهة المستخدم.

قد نحاول التعامل مع هذه الحالات عن طريق إضافة المزيد من props إلى المكون:

jsx
<ProgressBar
  label="Uploading..."
  showValue
  showIcon
  icon={<UploadIcon />}
  valuePosition="right"
/>

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

مكونات صغيرة تعمل معا

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

سنطبق الفكرة على ProgressBar يتكون من أربعة أجزاء:

  • ProgressBarTrack للمسار الكامل.
  • ProgressBarIndicator للمقدار المنجز.
  • ProgressBarLabel لاسم العملية أو وصفها.
  • ProgressBarValue للقيمة الحالية.
مخطط يوضح تقسيم مكون ProgressBar إلى أجزاء منفصلة: ProgressBarLabel للعنوان، ProgressBarTrack مع ProgressBarIndicator بداخله و ProgressBarValue لعرض النسبة المئوية
هيكل مكون Compound Components: تقسيم Progress Bar إلى أجزاء صغيرة قابلة للتركيب

المنطق المشترك داخل Hook

تحتاج الأجزاء كلها إلى النسبة الحالية و بعض خصائص الوصول. سنحسب هذه البيانات مرة واحدة داخل Hook مخصص ثم نشارك النتيجة مع الأجزاء.

tsx
// 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 ليجهز خصائص الوصول للعنصر الرئيسي و للتسمية:

tsx
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 يقرأ منه كل جزء.

tsx
// 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

أصبح بإمكاننا الآن بناء المكون الأب:

tsx
// 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 و يترك ترتيبها لمن يستخدم المكون. تبدو العلاقة بينها هكذا:

txt
ProgressBar
├── ProgressBarLabel
├── ProgressBarValue
└── ProgressBarTrack
   └── ProgressBarIndicator
مخطط يوضح البنية الهرمية لمكونات Progress Bar الأربعة: ProgressBar كعنصر جذر يحتوي على ProgressBarLabel و ProgressBarValue و ProgressBarTrack، حيث يحتوي ProgressBarTrack على ProgressBarIndicator
علاقة أجزاء ProgressBar بالمكون الأب

مكونات ProgressBar الداخلية

لا يحتاج المسار و المؤشر إلا إلى بنية العنصر و نسبة التقدم:

tsx
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:

tsx
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:

tsx
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:

tsx
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 بنية واحدة. يمكننا إضافة التسمية:

tsx
<ProgressBar label="Uploading...">
  <ProgressBarLabel>Uploading...</ProgressBarLabel>
 
  <ProgressBarTrack>
    <ProgressBarIndicator />
  </ProgressBarTrack>
</ProgressBar>

أو حذفها إذا لم نحتج إليها:

tsx
<ProgressBar aria-label="Uploading files">
  <ProgressBarTrack>
    <ProgressBarIndicator />
  </ProgressBarTrack>
</ProgressBar>

و يمكننا عرض القيمة من دون إضافة خيار جديد إلى المكون الأب:

tsx
<ProgressBar aria-label="Uploading files">
  <ProgressBarValue />
  <ProgressBarTrack>
    <ProgressBarIndicator />
  </ProgressBarTrack>
</ProgressBar>

كما نستطيع تخصيص محتوى التسمية:

tsx
<ProgressBar label="Uploading files...">
  <ProgressBarLabel>
    <span>Uploading files...</span>
  </ProgressBarLabel>
  <ProgressBarTrack>
    <ProgressBarIndicator />
  </ProgressBarTrack>
</ProgressBar>
ثلاثة أمثلة لشريط تقدم: الأول يعرض تسمية بلا نسبة ظاهرة و الثاني يعرض تسمية مع 18% و الثالث يعرض المسار وحده. توجد علامة صح خضراء خارج كل مثال
يمكن اختيار أجزاء ProgressBar و ترتيبها بطرق مختلفة

ما الذي وفره هذا النمط؟

يعالج نمط Compound Components مشكلة المكون الذي تكبر واجهته مع كل خيار جديد. يقسمه إلى أجزاء تشترك في الحالة عبر Context ثم يترك لمستخدمه اختيار هذه الأجزاء و ترتيبها.

في الدرس التالي ستطبق الفكرة ببناء مكون تقييم من أجزاء متعاونة.

اللوحات
نوع الخط
حجم الخط
أكملت 0 من 12 درس

Component Composition

1.المكونات المركبة (Compound Components)

React Design Patterns112 المكونات المركبة (Compound Components)مكافأة إكمال الدرس إضافة إلى نقاط أنشطته. 10 XP. تُمنح المكافأة مرة واحدة فقط.