معظم أخطاء الواجهة تجميلية. لكن بعضها يغيّر أرقاماً لم يقصد المستخدم تغييرها، وإذا كانت تلك الأرقام هي الرافعة المالية أو حجم الأمر أو السعر، فلم يعد الخطأ تجميلياً.
هذان خطآن واجهناهما، وكلاهما ناتج عن سلوك متصفح ومكتبة موثّق تقنياً لكنه غير مرئي عملياً. الإصلاحات صغيرة، أما العثور عليها فلم يكن كذلك.
أهم النقاط
- حقل <input type="number"> عندما يكون مُركّزاً يزيد قيمته أو ينقصها حين تُمرّر عجلة الفأرة فوقه. وعلى حقل مالي فإن هذا يعيد كتابة القيمة في صمت.
- أصلحه بـ blur() لا بـ preventDefault(): إزالة التركيز تلغي الزيادة مع إبقاء تمرير الصفحة والنافذة الحوارية سليماً تماماً.
- قائمة Radix المنبثقة المنقولة عبر portal إلى document.body لا تستطيع الاحتفاظ بالتركيز داخل نافذة Radix الحوارية، لأن نطاق تركيز النافذة يسحب التركيز خارجها. يبدو الحقل حياً وهو يبتلع كل ضغطة مفتاح.
- مرّر عنصر النافذة الحوارية نفسه بوصفه حاوية الـ portal للقائمة المنبثقة كي تصبح جزءاً من الشجرة الفرعية المحصورة.
- هذا الإصلاح يُبطل معالجات النقر خارج العنصر المكتوبة يدوياً: بمجرد نقل المحتوى عبر portal لم يعد سليلاً في DOM للغلاف الذي كنت تقيس النقر بالنسبة إليه.
1. العجلة إيماءة تحرير، ولم يخبر أحدٌ المستخدم بذلك
حقول الأرقام الأصلية لها سلوك يعود إلى المواصفة نفسها: حين يكون الحقل مُركّزاً، فإن تمرير عجلة الفأرة فوقه يرفع القيمة أو يخفضها بمقدار خاصية step الخاصة به.
على منتقي كمية في سلة تسوق، هذا إزعاج بسيط. أما في نماذجنا فهو أمر مختلف تماماً، لأن الحقول المعنية هي الرافعة المالية ومبلغ التداول ونسبة حصة الأرباح وسعر الإدراج. ينقر المستخدم داخل حقل، ثم يمرّر الصفحة ليقرأ بقية النموذج، فيجد أن الرقم الذي ضبطه من قبل قد تغيّر. لا شيء يعلن عن ذلك. النموذج لا يزال يجتاز التحقق. ويبدأ البوت بقيمة لم يخترها المستخدم قط.
الإصلاح الساذج هو إرفاق معالِج wheel بكل حقل واستدعاء preventDefault(). هذا ينجح، لكنه يكسر التمرير أيضاً: لم يعد بمقدور المستخدم تمرير الصفحة بينما يصادف وجود المؤشر فوق حقل رقمي، وهو ما يبدو معطلاً على نحو مختلف تماماً.
الإصلاح الأفضل يستفيد من الشرط المسبق الوارد في المواصفة. فالزيادة لا تحدث إلا حين يكون الحقل مُركّزاً، لذا يكفي إزالة التركيز:
const onWheel = (e) => {
const active = document.activeElement;
if (
active instanceof HTMLInputElement &&
active.type === "number" &&
(active === e.target || active.contains(e.target))
) {
active.blur(); // kills the increment, leaves scrolling intact
}
};
document.addEventListener("wheel", onWheel, { passive: true, capture: true });
ثلاثة خيارات مقصودة في هذا المقطع:
blur()بدلاً منpreventDefault(). نحن لا نلغي التمرير، بل نزيل الشرط الذي يجعله تحريراً. تبقى الصفحة تُمرَّر بشكل طبيعي.capture: true. يراقب المستمِع الحدث في مرحلة النزول، فيظل يعمل حتى لو أوقف مكوّن داخلي انتشار الحدث.passive: true. آمن على وجه الدقة لأننا لا نستدعيpreventDefault()أبداً، وهو يُبقي المستمِع بعيداً عن المسار الحرج للتمرير.
مستمِع عام واحد، يُركَّب مرة واحدة، يغطي كل حقل رقمي في التطبيق، بما في ذلك الحقول التي لم توجد بعد. أما البديل، وهو ترقيع كل حقل، فيفشل في اللحظة التي يضيف فيها أحدهم نموذجاً جديداً وينسى.
2. قائمة منبثقة داخل نافذة حوارية يتعذّر الكتابة فيها
الخطأ الثاني ظهر بوصفه بلاغ خطأ مستحيلاً: مربع بحث داخل قائمة منسدلة، داخل نافذة منبثقة، يُركَّز عليه بوضوح ثم يتجاهل كل ما يُكتب فيه.
السبب هو تصادم سلوكين صحيحين.
قوائم Radix المنبثقة تنقل محتواها عبر portal إلى document.body بشكل افتراضي، وهذا صحيح على صفحة عادية، لأنه يتجاوز سياقات الفيضان (overflow) والتراص (stacking). ونوافذ Radix الحوارية تحصر التركيز داخل شجرتها الفرعية الخاصة، وهذا صحيح أيضاً، وهو ما يجعل النافذة المنبثقة نافذة حوارية بالفعل.
اجمع بينهما، فيصبح محتوى القائمة المنبثقة، من منظور DOM، خارج النافذة الحوارية. نطاق تركيز النافذة يفحص كل حدث تركيز وارد تقريباً على هذا النحو:
container.contains(target) ? allow : focus(lastFocusedElement)
مربع البحث ليس محتوى داخل النافذة الحوارية، لذا كان كل حدث تركيز يقع عليه يُلغى فوراً. الحقل يُعرَض، ويُظهر مؤشر كتابة للحظة، ثم يفقد التركيز قبل أن تُسجَّل ضغطة مفتاح. بدا حياً وابتلع كل شيء.
الإصلاح هو الكفّ عن الكذب بشأن مكان وجود القائمة المنبثقة. الـ portal في Radix يقبل خاصية container، لذا نعرض عنصر div فارغاً بارتفاع صفري داخل النافذة الحوارية وننقل القائمة المنبثقة إليه عبر portal:
// inside the dialog
<div ref={setDropdownHost} />
...
<PopoverContent container={dropdownHost}>…</PopoverContent>
تفصيلان يسهل الخطأ فيهما:
- حالة (state)، لا مرجع (ref). العنصر المضيف لا يوجد عند أول عرض (render)، والـ portal يحتاج إلى إعادة عرض بمجرد وجوده. مرجع
useRefسيحتفظ بالعنصر لكنه لن يُطلق تلك الإعادة أبداً. - يجب ألا يؤثر المضيف في التخطيط. فهو فارغ وبارتفاع صفري، لذا يمكنه أن يجلس في أي مكان ملائم داخل ترميز النافذة الحوارية دون أن يزيح شيئاً.
3. الإصلاح الذي يكسر في صمت الكود المجاور له
هذا هو الجزء الذي كنت سأنبّه إليه بأشد ما يكون، لأنه المكان الذي يتحول فيه إصلاح نظيف إلى انحدار (regression).
قبل الانتقال إلى قائمة منبثقة منقولة عبر portal، كان للقائمة المنسدلة معالِج نقر خارج العنصر مكتوب يدوياً: احتفظ بمرجع على الغلاف، واستمع إلى نقرات المستند، وأغلق إن لم تكن النقرة بداخله.
بمجرد نقل المحتوى عبر portal، لم يعد سليلاً في DOM لذلك الغلاف، وإن كان بصرياً بداخله. لذا صار المعالِج الآن يرى النقرات على مربع بحث القائمة نفسها بوصفها نقرات خارجها، فيغلق الشيء ذاته الذي يحاول المستخدم استخدامه.
إزالة ذلك المعالِج لم تكن تنظيفاً، بل كانت جزءاً من الإصلاح. القاعدة العامة:
النقل عبر portal يكسر كل افتراض مبني على الاحتواء في DOM. معالجات النقر خارج العنصر، واستعلامات
closest()، وتفويض الأحداث (event delegation)، ومحدِّدات CSS الخاصة بالسليل، كلها تغيّر معناها في صمت. حين تنقل شيئاً عبر portal، اذهب وابحث عن الكود الذي افترض الاحتواء.
ما المشترك بين هذين الخطأين
كلا الخطأين يقع في الفجوة بين «المكتبة تتصرف بشكل صحيح» و«المستخدم يمرّ بوقت سيئ». لم يُرمَ أي استثناء. لم يُسجَّل شيء في السجلّات. وفي كلتا الحالتين بدا المكوّن سليماً في لقطة شاشة.
الدروس العامة التي سأحتفظ بها:
- اعرف أيّ الإيماءات تعامله المنصة تحريراً. العجلة فوق حقل رقمي مُركّز واحدة منها؛ وكذلك اللصق بالنقر الأوسط على Linux. إن كان الحقل يحمل مالاً فدقّقها.
- فضّل إزالة شرط مسبق على إلغاء حدث. تفوّق
blur()علىpreventDefault()لأنه استهدف السبب بدلاً من كبح العَرَض، ولأنه ترك السلوك غير المتصل به دون مساس. - حُلّه مرة واحدة، عالمياً، حين تكون صيغة العطل هي «سينسى أحدهم». الإصلاح على مستوى كل حقل هو إصلاح له تاريخ انتهاء.
- حين تنقل عبر portal، دقّق افتراضات الاحتواء. فشجرة DOM والشجرة البصرية تكفّان عن الاتفاق، وأي كود خلط بينهما صار الآن خاطئاً.
