إمكانية الوصولCSS والتخطيطاترياكت

Popover API وعنصر ‎<dialog> الأصيل: نهاية النوافذ المنبثقة المبنية يدويًا من الصفر

Popover API وعنصر <dialog> الأصيل: نهاية النوافذ المنبثقة المبنية يدويًا من الصفر

في مقال إتاحة الوصول للمكوّنات، عرضتُ نافذة منبثقة كانت تختفي بصريًّا عبر display: none، لكنها ظلّت موجودة في شجرة إتاحة الوصول، لأنّ أحدهم أخفاها بطريقة غير صحيحة. لم تكن تلك سوى واحدة من خمس مشكلات يجب على النافذة المنبثقة المبنية من الصفر باستخدام <div> أن تحلّها يدويًّا لكي تُعدّ قابلة للوصول أصلًا: فخّ التركيز (لا يمكن لمفتاح Tab أن يُخرج المستخدم من النافذة المنبثقة)، والإغلاق بمفتاح Escape، والإغلاق بالنقر خارج المحتوى، وإعادة التركيز إلى العنصر الذي فتح النافذة المنبثقة، والعرض فعليًّا فوق بقية الصفحة، بصرف النظر عن عدد المكوّنات على الطريق التي تمتلك overflow: hidden أو z-index خاصًّا بها.

jsx

// تبدو كنافذة منبثقة - لكنها لا تحلّ فعليًا أيّ من المشكلات الخمس أعلاه
const NaiveModal = ({ isOpen, onClose, children }) => {
if (!isOpen) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal">{children}</div>
</div>
);
};

هذا الكود لا يملك فخّ تركيز – فمفتاح Tab يُخرج مستخدم لوحة المفاتيح بحرّية إلى بقية الصفحة، رغم أنّ النافذة المنبثقة تحجب بصريًّا كلّ شيء. لا تُغلَق بمفتاح Escape. لا تُعيد التركيز إلى العنصر المُطلِق بعد الإغلاق. وتعتمد على ألّا يكون لأيّ عنصر أب في شجرة DOM خاصية overflow: hidden أو z-index أدنى من عنصر آخر في الصفحة – وهو افتراض يثبت خطأه بانتظام في التطبيقات الكبيرة. توجد مكتبات مثل Radix أو Headless UI بالضبط لحلّ هذه المشكلة مرّة واحدة وبشكل جيّد في جافاسكريبت. منذ سنوات قليلة، أصبح المتصفح يحلّ جزءًا من هذه المشكلة بنفسه، دون سطر واحد من JS.

الطبقة العليا (top layer): طبقة لا يطالها z-index

الآلية الأساسية التي يعتمد عليها كلّ من <dialog> وسمة popover هي الطبقة العليا (top layer) – طبقة عرض داخلية في المتصفح، فوق كامل بقية المستند فعليًّا، مستقلّة تمامًا عن z-index وَoverflow وسياقات التكديس (stacking contexts). العنصر المرقَّى إلى الطبقة العليا لا «يمتلك z-index مرتفعًا جدًّا» فحسب – بل يتوقّف عن كونه جزءًا من مكدّس طبقات الصفحة العادي أصلًا، فلا يستطيع أيّ عنصر أب بخاصية overflow: hidden أن يقصّه، ولا يستطيع أيّ عنصر مجاور بقيمة z-index: 9999 سخيفة أن يُغطّيه. هذه الآلية نفسها استخدمها المتصفح داخليًّا منذ زمن طويل لعناصر أصيلة مثل <video> في وضع ملء الشاشة – وكلّ ما فعلته Popover API وَ<dialog> هو إتاحتها للمطوّرين.

jsx

const ConfirmDialog = ({ onConfirm }) => {
const dialogRef = useRef(null);
return (
<dialog ref={dialogRef} className="confirm-dialog">
<p>هل تريد فعلًا حذف هذا العنصر؟</p>
<form method="dialog" className="confirm-dialog__actions">
<button type="submit" value="cancel">إلغاء</button>
<button type="submit" value="confirm" onClick={onConfirm}>
حذف
</button>
</form>
</dialog>
);
};
// في مكان آخر من المكوّن:
// dialogRef.current.showModal();

استدعاء .showModal() (وليس .show() – هذا خطأ شائع، فـ.show() يفتح نافذة غير مشروطة وبلا خلفية وبلا فخّ تركيز) يُشغِّل أربعة أمور مجّانًا في آنٍ واحد: الترقية إلى الطبقة العليا، وعرض العنصر الزائف ::backdrop الذي يُعتِّم بقية الصفحة، وفخّ التركيز (مفتاح Tab يدور دوريًّا داخل النافذة المنبثقة فقط)، وجعل بقية المستند inert – فتتوقّف العناصر خارج النافذة عن قابلية التركيز وتختفي من التفاعل بالنسبة لقارئ الشاشة، رغم بقائها مرئية تحت الخلفية المعتِّمة. الإغلاق بمفتاح Escape أو عبر <form method="dialog"> (آلية أصيلة – النقر على زرّ submit داخل نموذج كهذا يُغلق النافذة المنبثقة ويضبط dialog.returnValue على قيمة (value) الزرّ الذي نُقر، دون preventDefault ودون معالج في JS) يُعيد تلقائيًّا التركيز إلى العنصر الذي فتح النافذة. لا شيء من هذه الأمور الأربعة يتطلّب كتابته بنفسك.

Popover API: الشيء نفسه دون شرطية (modality)

يفترض <dialog> أنّ بقية الصفحة يجب أن تُحجَب. هذا مناسب لتأكيد عملية حذف، لكنه ثقيل جدًّا لقائمة منسدلة أو تلميح (tooltip) أو إشعار عابر (toast) – أشياء يجب أن تظهر فوق بقية المحتوى، وتُغلق نفسها بالنقر إلى جانبها، لكن دون أن تحجب التفاعل مع بقية الصفحة. لهذا الغرض تُستخدَم سمة popover، بشكل تصريحي، دون سطر واحد من جافاسكريبت:

html

<button popovertarget="user-menu">الحساب</button>
<div id="user-menu" popover>
<a href="/profile">الملف الشخصي</a>
<a href="/settings">الإعدادات</a>
<button popovertarget="user-menu" popovertargetaction="hide">تسجيل الخروج</button>
</div>

مجرّد العلاقة بين popovertarget وَid تكفي ليتولّى المتصفح كلّ الباقي: الترقية إلى الطبقة العليا، والإغلاق الخفيف (light dismiss – النقر في أيّ مكان خارج النافذة المنبثقة أو مفتاح Escape يُغلقها تلقائيًّا، دون الاستماع إلى حدث click على document)، وعلاقات ARIA الصحيحة بين المُطلِق والمحتوى (يربط المتصفح تلقائيًّا aria-expanded وَaria-controls بناءً على هذا الزوج من السمات). هذا بالضبط ما كان يتطلّب سابقًا مكتبة من نوع Floating UI أو استماعًا يدويًّا للنقرات خارج العنصر مع فحص إضافي عبر event.target.closest().

الفارق الجوهري مقارنة بـ<dialog> في وضعه المشروط: لا يجعل الـpopover بقية الصفحة inert، ولا يملك فخّ تركيز. هذا قرار واعٍ في المواصفة، لا نقص في الميزة – فقائمة المستخدم لا ينبغي أن تمنع إمكانية التمرير أو النقر في مكان آخر من الصفحة، كما تفعل نافذة منبثقة حقيقية. الخلط بين هاتين الأداتين في الاتجاه الخاطئ – استخدام popover حيث يجب على المحتوى فعليًّا حجب بقية الواجهة (مثل تأكيد عملية لا رجعة فيها) – ينتج واجهة يستطيع فيها مستخدم لوحة المفاتيح الخروج بالتاب في أيّ لحظة من «نافذة منبثقة» لم تكن أصلًا تحجبه.

فخّ قد يسكت عنه التوثيق أحيانًا: النقر على خلفية <dialog> لا يُغلقها تلقائيًّا

رغم أنّ ::backdrop تُعرَض مجّانًا، فإنّ النقر عليها لا يُغلق النافذة المنبثقة دون كود إضافي – وهذا أحد الأمور القليلة التي يجب إضافتها يدويًّا. الآلية التي تُتيح ذلك تعتمد على حقيقة محدَّدة حول اختبار النقر (hit-testing): عنصر <dialog> نفسه في الوضع المشروط يملأ المنطقة المرئية بأكملها لاختبار النقر، بصرف النظر عن صِغَر مربّع المحتوى المرئي بداخله – فالنقر على الخلفية المعتِّمة لا يزال يصيب event.target === dialogElement، أمّا النقر داخل المحتوى فيصيب عنصرًا محدَّدًا من أبنائه.

jsx

const handleBackdropClick = (event) => {
// event.target يكون هو <dialog> نفسه فقط عندما تصيب النقرة الخلفية،
// لا أيّ عنصر من المحتوى بداخله
if (event.target === dialogRef.current) {
dialogRef.current.close();
}
};
// <dialog ref={dialogRef} onClick={handleBackdropClick}>

يستحقّ هذا التفصيل أن يُحفَظ منفصلًا، لأنّ الحدس يقول «بما أنّ الخلفية تُعرَض تلقائيًّا، فمن المرجّح أنها تُغلَق تلقائيًّا أيضًا» – لكنها لا تُغلَق، وهي العنصر الوحيد في هذه المجموعة الذي يتطلّب سطر JS مكتوب بنفسك.

الفخاخ والممارسات الجيدة

  • .show() ليست مثل .showModal(). تفتح .show() نافذة غير مشروطة – دون ::backdrop، ودون فخّ تركيز، ودون inert على بقية الصفحة. لنافذة منبثقة حقيقية، تحتاج دائمًا إلى .showModal().
  • يجب إعادة ضبط الأنماط الافتراضية لـ<dialog> بوعي، تمامًا كما مع أيّ عنصر أصيل آخر من مقال إتاحة الوصول (<button>، <ul>) – يمنحه المتصفح حدًّا (border) وحشوًا (padding) وتوسيطًا افتراضيًّا تُلغيه غالبًا بأنماط CSS خاصة بك عمليًّا في كلّ مرّة.
  • الـpopover لا يحلّ محلّ <dialog> حيث تكون الشرطية مطلوبة. غياب فخّ التركيز وغياب inert قيد مقصود في الـpopover، لا ثغرة يجب تجاوزها – إن كان يجب على المحتوى حجب بقية الصفحة، فالأداة الصحيحة دائمًا هي <dialog>.showModal().
  • الدعم: <dialog> آمنة منذ زمن، وَpopover أحدث. تتمتع <dialog> (بما فيها showModal()) بدعم متين منذ عام 2022. أمّا سمة popover فبلغت مرتبة Baseline (متاحة على نطاق واسع) لاحقًا، في عام 2024 – لا يزال يستحقّ التحقّق من أدنى إصدار مدعوم من Safari في المشاريع ذات الذيل الطويل من الأجهزة القديمة، قبل التخلّي كليًّا عن مكتبة JS كخيار احتياطي.

الخلاصة

النافذة المنبثقة المبنية يدويًّا على <div> ليست سيّئة لأنّ المطوّر لم يبذل جهدًا كافيًا – بل لأنها تحاول إعادة إنتاج آلية في جافاسكريبت يقدّمها المتصفح الآن أصيلة ومجّانية: طبقة عليا مستقلّة عن z-index، وفخّ تركيز، وخاصية inert على بقية الصفحة، و::backdrop، وإعادة التركيز إلى المُطلِق. <dialog> وَpopover ليسا نسختين من الشيء نفسه – بل أداتان على طرفَي حدّ واحد: الشرطية التي تحجب بقية الواجهة عن وعي، والمحتوى الخفيف والمؤقّت الذي لا يحجبها عن وعي أيضًا. الاختيار بينهما ليس مسألة أسلوب، بل إجابة عن سؤال: هل ينبغي أن يستطيع المستخدم في تلك اللحظة فعل أيّ شيء آخر في الصفحة – وهذا سؤال يستحقّ أن تطرحه على نفسك قبل كتابة أوّل سطر من الكود، لا بعد أن تكتشف أنّ القائمة تمنع التمرير، أو أنّ نافذة تأكيد حذف الحساب لا تحجب مفتاح Tab إطلاقًا.