خشبة منهج React — المسرح الحي · ١٣ إقليم · بلا مهلة الستار مرفوع
الإقليم ٠٩١٣ إقليماً

الإقليم ٠٩ — ورشة الأزياء

يبني فوق: المكوّن كدالّة من props (١)، ref وتوصيلها بـDOM (٥)، إعادة استخدام المنطق (٦). النطاق: كيف تبني عنصراً تفاعلياً "جاهزاً" مرّةً واحدة، تثق به في كل الأشكال التي يظهر بها لاحقاً. يفتح: خارطة الجولة (١٠) — العناصر التفاعلية داخل روابط التنقّل تحديداً.

اللغز المستفزّ

ابنِ زرّاً. النسخة الأولى، مباشرة:

function Button({ children, onClick }: { children: Child; onClick?: () => void }) {
  return (
    <button
      onClick={onClick}
      className="h-10 px-4 rounded-md bg-blue-600 text-white font-medium
                 hover:bg-blue-700 focus-visible:ring-2 focus-visible:ring-blue-400
                 disabled:opacity-50 disabled:pointer-events-none transition-colors"
    >
      {children}
    </button>
  );
}

جيد. الآن يُطلَب منك: زرّ "حذف" يجب أن يكون أحمر (destructive)، وزرّ "إلغاء" يجب أن يكون بلا خلفية، بحدٍّ فقط (outline)، وفي مكانٍ آخر تحتاج نسخةً أصغر من كل هذه. حدسك الأول: انسخ Button والصق، غيّر الألوان، سمِّها DestructiveButton، OutlineButton... بعد ثلاثة مكوّنات منسوخة، تكتشف بقّة: نسيتَ disabled:pointer-events-none في OutlineButton — الآن الزر المعطَّل فيه لا يزال قابلاً للنقر بصرياً رغم أنه رمادي. تُصلحها في واحد، تنسى الاثنين الآخرين.

السؤال المستفزّ: خمس حالاتٍ (عادي/hover/active/focus/disabled) وسلوكٌ واحد تريده مطابقاً في كل نسخة — يتغيّر فقط الشكل (اللون، الحجم). أليس هذا بالضبط درس الإقليم ١ (شكلٌ واحد، بيانات/خيارات متعدّدة)، لكن على مستوى الـCSS بدل الـJSX؟

ليش وكيف: خارطة اختيارات → أصناف CSS، دالّة واحدة

الحلّ بنفس روح CourseCard من الإقليم ١: دالّةٌ واحدة، تأخذ اختيارات (variant, size) بدل بياناتٍ، وتُرجع نصّ الأصناف الصحيح. هذا النمط اسمه شائعٌ في مكتبات التصميم الحقيقية (CVA — class-variance-authority)، وجوهره بسيط جداً لدرجة أنك ستبنيه بيدك الآن:

type VariantMap = Record<string, Record<string, string>>;

function cvaNano<V extends VariantMap>(
  base: string,
  options: { variants: V; defaultVariants: { [K in keyof V]: keyof V[K] } },
) {
  return (props?: Partial<{ [K in keyof V]: keyof V[K] }>): string => {
    const classes = [base];
    for (const key of Object.keys(options.variants) as (keyof V)[]) {
      const chosen = props?.[key] ?? options.defaultVariants[key];
      classes.push(options.variants[key][chosen as string]);
    }
    return classes.join(" ");
  };
}

استعملها:

const buttonVariants = cvaNano(
  // "base": الخمس حالات، مكتوبةٌ مرّةً واحدة، تُطبَّق على كل صنفٍ تلقائياً
  "h-10 px-4 rounded-md font-medium transition-colors focus-visible:ring-2 " +
  "disabled:opacity-50 disabled:pointer-events-none",
  {
    variants: {
      variant: {
        primary: "bg-blue-600 text-white hover:bg-blue-700",
        outline: "border border-gray-300 hover:bg-gray-100",
        destructive: "bg-red-600 text-white hover:bg-red-700",
      },
      size: {
        sm: "h-8 px-3 text-sm",
        md: "",                          // القيم الافتراضية موجودة في base أصلاً
        lg: "h-12 px-6 text-lg",
      },
    },
    defaultVariants: { variant: "primary", size: "md" },
  },
);

buttonVariants({ variant: "destructive", size: "sm" });
// → "h-10 px-4 rounded-md ...(base)... bg-red-600 text-white hover:bg-red-700 h-8 px-3 text-sm"

النقطة الجوهرية: الخمس حالات (hover, focus-visible, disabled...) مكتوبةٌ مرّةً واحدة فقط في base، وتُطبَّق على كل تركيبة variant×size تلقائياً — البقّة التي أنتجتَها في اللغز الاستفزازي (نسيان disabled:pointer-events-none في نسخةٍ منسوخة) تصبح مستحيلة بنيوياً، لأنه لا توجد نسخٌ لتنسى تحديث إحداها أصلاً.

اللغز/البناء من الصفر — الجزء الأول

اللغز أ. ابنِ cvaNano كما أعلاه، واستعملها لبناء Button بثلاثة variants وثلاثة أحجام (تسع تركيبات) بلا نسخ أي كتلة CSS مرّتين. أفسِد عمداً base (احذف disabled:pointer-events-none منه) وتحقّق أن كل زرٍّ معطَّل في كل التركيبات التسع يفقد نفس الحماية دفعة واحدة — ثم أصلحه في مكانٍ واحد وشاهد كل التركيبات تُصلَح معاً.

ليش وكيف: ref لا يعبر مكوّناً عادياً تلقائياً

من الإقليم ٥، تعرف كيف تُركِّز حقلاً برمجياً عبر ref. جرّب نفس الشيء على Button الذي بنيتَه للتو:

function SubmitPanel() {
  const buttonRef = useRefNano<HTMLButtonElement | null>(null);

  useEffect(() => {
    buttonRef.current?.focus();
  }, []);

  return <Button ref={buttonRef}>إرسال</Button>;    // ⚠️
}

شغّلها واطبع buttonRef.current بعد الرسم. ستجده null، رغم أن الزر مرسومٌ فعلياً على الشاشة! لماذا فشل ما نجح تماماً في الإقليم ٥ مع <input> مباشرة؟ لأن Button دالّةٌ عادية بمعاملٍ واحد (props) — وref كخاصية خاصة (تذكّر الإقليم ٥) لا تُمرَّر كجزءٍ من props لمكوّنٍ عادي؛ React يعترضها قبل أن تصل لجسم الدالّة إطلاقاً. Button لا يملك طريقة ليعرف أن أحداً مرّر له ref، فضلاً عن توصيله لعنصر <button> الداخلي.

الحلّ: توقيعٌ خاص، يستقبل معاملين منفصلين:

type ForwardRefComponent<P> = {
  __forwardRef: true;
  render: (props: P, ref: { current: unknown } | undefined) => Child;
};

function forwardRefNano<P>(
  render: (props: P, ref: { current: unknown } | undefined) => Child,
): ForwardRefComponent<P> {
  return { __forwardRef: true, render };
}

ويحتاج محرّكك (من createDom/patch) أن يتعرّف على هذا النمط ويستدعيه بطريقةٍ مختلفة عن مكوّنٍ عادي — استخراج props.ref صراحةً ومرورها كمعامل ثانٍ منفصل، لا كجزءٍ من props الممرَّرة لجسم render.

const Button = forwardRefNano<ButtonProps>((props, ref) => {
  const { variant, size, className, children, ...rest } = props;
  return h("button", {
    ...rest,
    ref,                                              // يُوصَّل الآن فعلياً
    className: buttonVariants({ variant, size }) + (className ? " " + className : ""),
  }, children);
});

اللغز/البناء من الصفر — الجزء الثاني

اللغز ب. وسّع createDom/patch ليتعرّفا على ForwardRefComponent (تحقّق typeof vnode.type === "object" && "__forwardRef" in vnode.type)، ويستخرجا props.ref ويمرّراها كمعامل ثانٍ لدالّة render، بدل تجاهلها. أعد تجربة SubmitPanel أعلاه بـButton المبني بـforwardRefNano. معيار النجاح: buttonRef.current يجب أن يشير للعنصر الحقيقي، والزر يُركَّز تلقائياً عند الظهور — بلا أي تغييرٍ في SubmitPanel نفسه، فقط في Button.

ليش: رابطٌ يريد أن "يبدو" زرّاً — مشكلةٌ جديدة، حلٌّ مألوف

تحتاج الآن رابط تنقّل (يذهب لصفحة التسجيل، لا يُنفّذ onClick فقط) يبدو مطابقاً تماماً لـButton. ثلاث محاولات، كلها معيبة:

  1. <Button onClick={() => navigate("/register")}> — يعمل بصرياً، لكنه ليس رابطاً حقيقياً: لا href (لا فتح في تبويبٍ جديد بالنقر الأوسط، لا معاينة رابطٍ عند التحويم، أسوأ لمحرّكات البحث وقارئات الشاشة).
  2. <Button><Link to="/register">سجل</Link></Button> — الآن <a> داخل <button>، عنصرا تفاعلٍ متداخلان، غير صالحٍ دلالياً وغالباً مكسورٌ بصرياً (padding مضاعف، سلوك تركيزٍ متضارب).
  3. <Link to="/register" className={buttonVariants({variant:"primary"})}> — يعمل! لكن أصناف الزر الآن مكرَّرة يدوياً في كل مكانٍ تحتاج فيه "شيئاً يبدو كزر لكنه ليس زراً" — نفس درس الإقليم ١ يعود من جديد: شكلٌ واحد، منطقُ إنتاجه محبوسٌ في Button فقط، ومستنسَخٌ يدوياً في كل مكانٍ آخر.

السؤال المستفزّ: ماذا لو استطاع Button أن "يمنح" شكله وسلوكه لعنصرٍ آخر تماماً — يُخبره "كن أنت في الـDOM، لكن احمل صنوفي وخصائصي" — بدل أن يُصرّ على إنشاء <button> خاصٍّ به دائماً؟

ليش: asChild — دمج عنصرين في جسدٍ واحد

الفكرة: حين تُمرَّر خاصية asChild، لا يبني Button عنصر <button> خاصاً به — بدلاً من ذلك يأخذ العنصر الوحيد الذي مُرِّر له كـchildren، ويُدمج خصائصه (الأصناف، onClick، ref) داخله هو، منتجاً عنصراً واحداً فقط في الشجرة النهائية — لا غلافاً حول غلاف. الآلية جوهرها استنساخٌ لعنصر الابن بخصائص مدموجة، لا تغليفٌ حوله. هذا بالضبط جوهر ما تسمّيه مكتبات كـRadix/base-ui Slot: "افتح فتحةً، ودَع من يستخدمك يقرّر العنصر الحقيقي".

التوقيعان اللذان تحتاج بناءهما بيدك (لا حلّ جاهز هنا):

// يُرجع عنصراً جديداً بنفس type الابن، لكن بخصائصَ مدموجة من extra —
// انتبه لـclassName تحديداً: يُدمَج نصّياً (كلا الجانبين يبقيان)، لا يُستبدَل
function cloneWithMergedProps(child: VNode, extra: Record<string, unknown>): VNode;
// لو asChild: استنسخ الابن الوحيد (children) بأصناف/خصائص Button مدموجة،
// بلا استدعاء h("button", ...) إطلاقاً. لو لا: السلوك المعتاد كما بنيتَه سابقاً.
function Button({ asChild, variant, size, className, children, ...rest }: ButtonProps): Child;

الاستعمال الذي يجب أن تصل إليه:

<Button asChild variant="primary">
  <Link to="/register">سجل الآن</Link>
</Button>

النتيجة النهائية المطلوبة في DOM: عنصر <a> واحد فقط، يحمل كل أصناف الزر — لا <button> يلفّه، لا تكرار يدوي لأصناف CVA. Button حين ينجح لا "يرسم نفسه"؛ يمنح شكله وسلوكه لعنصرٍ آخر بالكامل.

اللغز/البناء من الصفر — الجزء الثالث

اللغز ج. ابنِ cloneWithMergedProps وButton بدعم asChild من التوقيعين أعلاه فقط، بيدك بالكامل. أعد إنتاج المحاولة الثالثة (تكرار الأصناف يدوياً) أولاً وتحقّق أنها تعمل لكن بتكرار، ثم استبدلها بـasChild الذي بنيتَه وتحقّق: افحص DOM الناتج فعلياً (container.innerHTML) وتأكّد أنه عنصر <a> واحدٌ بلا أي غلاف <button> حوله، حاملاً كل أصناف Button دون كتابتها يدوياً في مكانين.

ليش: Headless — افصل "كيف يتصرّف" عن "كيف يبدو"

Button أعلاه بسيط: زخرفٌ بصريٌّ فوق سلوكٍ بديهي (نقرة = نقرة). لكن عناصر أعقد — نافذة منبثقة (popover)، قائمة منسدلة — لها سلوكٌ صعبٌ حقاً يتكرّر بصرف النظر عن الشكل: إغلاقٌ بمفتاح Escape، إغلاقٌ بالنقر خارجها، عودة التركيز للزر الذي فتحها. اكتب هذا السلوك مرّةً واحدة، بمعزلٍ تام عن أي تصميمٍ بصري — هذا ما يعنيه headless:

function useDisclosure() {
  const [isOpen, setIsOpen] = useState(false);
  const open = () => setIsOpen(true);
  const close = () => setIsOpen(false);

  useEffect(() => {
    if (!isOpen) return;
    function handleKeyDown(e: KeyboardEvent) {
      if (e.key === "Escape") close();
    }
    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);   // الإقليم ٣
  }, [isOpen]);

  return { isOpen, open, close };
}

هذا الخطاف (بناءً مباشراً على الإقليم ٦) لا يعرف شيئاً عن الألوان أو التخطيط — يحلّ فقط "متى مفتوح، ومتى يُغلَق تلقائياً". أي تصميمٍ بصري (نافذةٌ عائمة، شريطٌ جانبي، تبويب) يقدر يستهلكه بلا إعادة حلّ منطق Escape من الصفر. هذا الفصل — سلوكٌ في خطاف، شكلٌ في مكوّنٍ يستهلكه — هو نفس حدّ الإقليم ٨ (شكل/منطق)، مطبَّقاً على عناصر تفاعلية معقّدة بدل صفحاتٍ كاملة.

اللغز د. ابنِ useDisclosure كما أعلاه، واستهلكه من شكلين مختلفين بصرياً تماماً (نافذةٌ منبثقة في المنتصف، وشريطٌ جانبي ينزلق من الحافة) — كلاهما يستخدم نفس منطق Escape بلا إعادة كتابته. تحقّق أن الضغط على Escape يُغلق كليهما بشكلٍ صحيح.

الخلاصة — أين تتّصل هذه العقدة بالشجرة

الخلاصة
  • ما بنيتَه: cva لإدارة تركيبات الشكل من نقطةٍ واحدة (يمنع بقّة الحالة المنسية بنيوياً)؛ forwardRef ليعبر مرجع DOM حاجز دالّة المكوّن (امتدادٌ مباشر للإقليم ٥)؛ asChild/Slot لدمج شكلٍ وسلوكٍ في عنصرٍ آخر بلا غلافٍ زائد ولا تكرار أصناف؛ وخطافاً headless يفصل سلوكاً معقّداً عن أي شكلٍ بصري.
  • العقدة الجديدة: *نظام مكوّناتٍ موثوق* — الطبقة التي تجعل "ابنِ زرّاً مرّةً، استعمله في كل مكان بثقة" ممكناً فعلياً، لا شعاراً.
  • بابٌ مفتوح: كل مثالٍ هنا كان عنصراً مستقلاً. لكن Link الذي استعملته مع asChild ينتمي لمنظومةٍ أكبر — التنقّل بين صفحات كاملة، حيث "أي صفحة تُعرَض الآن" هي حالةٌ خاصة جداً: الرابط نفسه في المتصفّح. → الإقليم ١٠.

الوصلة بمشروعك

الوصلة بمشروعك

shared/ui/button.tsx عندكم يستعمل بالضبط هذه الأدوات الثلاث معاً: cva حقيقية (لا نسختنا المصغّرة) لتوليد الأصناف، forwardRef من React لتمرير المرجع، وعنصر base-ui/react (بديل Radix) الذي يدعم asChild داخلياً بنفس الآلية التي بنيتَها. ونظام components.json في مشروعكم ("style": "base-maia") هو تحديداً اختيار أي مكتبة headless تبني فوقها هذا كله — القرار الذي تدرسه هنا نظرياً هو قرارٌ حقيقيٌّ اتُّخِذ في مشروعكم.

البذرة التالية: استعملنا Link في هذا الإقليم كأنه بديهي. لكن كيف "يعرف" التطبيق كله أي صفحةٍ يعرض الآن، وكيف يتغيّر ذلك بلا إعادة تحميل الصفحة بالكامل؟ هذه آخر آلة أساسية بقيت غامضة.