CSS :has() — الأصل الذي يعرف ما يجري في داخله
CSS :has() — الأصل الذي يعرف ما يجري في داخله
لديك حقل نموذج: <div class="field"> يحوي تسمية (label) وحقل إدخال بداخله. المتطلب يبدو بديهيًا – عندما يكون الإدخال غير صحيح، يجب أن تحصل الحاوية بأكملها على إطار أحمر وأيقونة تحذير بجانب التسمية، لا حقل الإدخال وحده. تلجأ إلى .field input:invalid – فتصطدم بجدار. هذا المحدِّد كان سيُنسّق حقل الإدخال لو أردت له إطارًا أحمر. لكنك تريد تنسيق .field، أي أصل ذلك الإدخال، بناءً على ما يجري في داخله. وفجأة يتضح أن CSS – رغم عشرات أدوات الربط والأصناف الزائفة (pseudo-classes) ومحدِّدات الخصائص – لم يوفّر قط، طوال تاريخه الممتد 25 عامًا، وسيلة لفعل ذلك.
هذا ليس ثغرة في معرفتك بـCSS. إنها سمة جوهرية في بنية المحدِّدات (selectors)، سرت من CSS1 وحتى عام 2022.
لماذا ينظر أداة الربط دائمًا في اتجاه واحد فقط
كل أداة ربط في CSS – المسافة (السليل descendant)، و> (الابن المباشر)، و+ (الشقيق المجاور)، و~ (الشقيق العام) – تصف علاقة بين محدِّدَين، لكن العنصر الذي يُنسَّق دائمًا هو الواقع على يمين أداة الربط. .field input يُنسِّق حقل الإدخال داخل .field. و.field ~ .error يُنسِّق .error الذي هو شقيق لـ.field. الاتجاه ثابت دائمًا: من السياق إلى الهدف، لا العكس أبدًا. محرك CSS، عند مروره بـ.field، لا يملك آلية مدمجة لـ«التطلع إلى الداخل» وتغيير قرار تنسيق .field نفسها بناءً على ذلك.
التفّ المطوّرون على هذا لسنوات بطريقتين، ولكل منهما تكلفة حقيقية. الأولى – جافاسكريبت ينصت لحدث input/blur ويضيف يدويًا صنف .field--invalid إلى الأصل. والثانية، الأذكى لكن الأكثر هشاشة – ما يُعرف بحيلة checkbox/radio، التي تستغل ~ لتنسيق الأشقاء بناءً على حالة :checked، وهي لا تعمل إلا حين تكون العناصر فعليًا أشقاء في بنية DOM مسطّحة، لا متداخلة كما يتطلب النموذج الحقيقي.
jsx
// حل بديل من قبل :has() – يعمل، لكنه يتطلب جافاسكريبت لأمر// هو أصلًا نتيجة بصرية بحتة لحالة HTMLimport { useState } from "react";const FormField = ({ label, ...inputProps }) => {const [isInvalid, setIsInvalid] = useState(false);return (<div className={`field ${isInvalid ? "field--invalid" : ""}`}><label>{label}</label><input{...inputProps}onBlur={(e) => setIsInvalid(!e.target.validity.valid)}/></div>);};
لا تُلغي :has() هذا الكود لأنها حيلة أذكى – بل لأنها تحلّ المشكلة من جذورها، بوصفها أول صنف زائف علائقي (relational pseudo-class) في تاريخ CSS يسمح للعنصر بأن يسأل عن داخله.
كيف تعمل :has() فعليًا: محدِّد مُرسى في العنصر نفسه، لا في السليل
التحوّل الأساسي في التفكير هنا: لا تُنسِّق .field:has(input:invalid) حقل الإدخال. بل تُنسِّق .field – العنصر ذاته الذي أُلحق به الصنف الزائف – بشرط وجود عنصر في مكان ما داخله يطابق المحدِّد الموجود بين القوسين. العنصر الذي «ترتكز» عليه (anchor element) يظل هو نفسه دائمًا؛ وكل ما تفعله :has() هو تحديد ما إذا كان سيحصل على تطابق أصلًا.
css
.field:has(input:invalid) {border-color: #c62828;}.field:has(input:invalid) .field__icon {visibility: visible;}
افتراضيًا، يبحث المحدِّد داخل :has() عن أي سليل، على أي عمق – تمامًا كالمسافة العادية. لكن يمكنك تضييق العلاقة بالطريقة نفسها المتّبعة في CSS العادي، باستخدام أدوات الربط مباشرة داخل القوسين:
css
/* الابن المباشر فقط، لا أي سليل */.card:has(> img) {grid-template-columns: 120px 1fr;}/* عنصر يليه مباشرة .error-message كشقيق */.field:has(+ .field-hint) {margin-bottom: 4px;}
هذا يجعل من :has() ليست مجرد حيلة جديدة واحدة، بل تعميمًا (generalization) – فهي تتيح التعبير في CSS عن أي علاقة كان يمكن وصفها سابقًا بأداة ربط، لكن موجّهة «إلى الداخل» أو «إلى الوراء»، بدلًا من كونها موجهة «إلى الأمام» حصرًا.
مثال عملي: حقل نموذج يعرف بنفسه أنه غير صحيح
لنجمع هذا في مكوّن كامل ومتاح لذوي الاحتياجات. تفصيل مهم: تُفعَّل :invalid من تلقائها فور تحميل حقل فارغ يحمل الخاصية required – قبل أن يكتب المستخدم أي شيء، وهو ما كان سيعطي إطارًا أحمر على نموذج فارغ لم يُلمس بعد. تحلّ هذا :not(:placeholder-shown)، التي تطابق الحقل فقط عندما يكون المستخدم قد ترك فيه فعليًا شيئًا ما.
jsx
const FormField = ({ label, id, ...inputProps }) => (<div className="field"><label htmlFor={id}>{label}</label><input id={id} {...inputProps} /><svg className="field__icon" aria-hidden="true" viewBox="0 0 20 20"><path d="M10 2a8 8 0 100 16 8 8 0 000-16zm1 12H9v-2h2v2zm0-4H9V5h2v5z" /></svg></div>);export default FormField;
css
.field {position: relative;border: 1px solid #ccc;border-radius: 8px;padding: 8px 12px;transition: border-color 0.15s ease;}.field__icon {position: absolute;right: 12px;top: 50%;transform: translateY(-50%);width: 18px;fill: #c62828;visibility: hidden;}/* الحقل «تمت ملامسته» (كُتب فيه شيء) وغير صحيح – عندئذٍ فقط تفاعل */.field:has(input:not(:placeholder-shown):invalid) {border-color: #c62828;background: #fff5f5;}.field:has(input:not(:placeholder-shown):invalid) .field__icon {visibility: visible;}/* التأكيد الإيجابي لا يقل أهمية عن الخطأ */.field:has(input:not(:placeholder-shown):valid) {border-color: #2e7d32;}
لا سطر واحد من جافاسكريبت مسؤول عن المظهر. حالة التحقق موجودة أصلًا وبشكل أصلي في المتصفح – فـ:valid/:invalid موجودتان منذ CSS3 – وقد سمحت :has() أخيرًا بنقل هذه الحالة الموجودة من حقل الإدخال إلى حاويته، حيث كانت مطلوبة بصريًا منذ الأزل.
النمط الثاني: مكوّن يعرف ما يحتويه
الآلية نفسها تحلّ فئة مختلفة تمامًا من المشكلات – تخطيط يعتمد على وجود محتوى معيّن، دون خاصية (prop) تُخبر بذلك مسبقًا.
css
/* البطاقة التي تحتوي على صورة تحصل على تخطيط بعمودين، والبطاقة بلا صورة تحصل على عرض نص كامل */.card:has(> img) {display: grid;grid-template-columns: 96px 1fr;gap: 12px;}/* يحصل عنوان القسم على مسافة من الأسفل فقط إذا كان للقسم فعليًا عنوان فرعي */.section:has(> .section__subtitle) .section__title {margin-bottom: 4px;}/* القائمة الخالية من أي عنصر تُظهر حالة فارغة بدلًا من مساحة خالية */.product-list:not(:has(li)) {display: block;}.product-list:not(:has(li))::after {content: "لا توجد منتجات مطابقة للمعايير.";color: #666;}
بدون :has()، كانت كل حالة من هذه الحالات تتطلب إما خاصية (hasImage، isEmpty) يضبطها المكوّن الأصل يدويًا، أو فحص طول المصفوفة داخل JSX وعرض صنف منفصل بشكل شرطي. تتيح :has() للمكوّن أن يتعرف بنفسه على محتواه ويتفاعل معه، بدلًا من أن يُخبَر به من الخارج – وهذا بالضبط الاتجاه الفكري نفسه الذي رأيناه مع بطاقة المنتج الواعية بحاويتها في مقال استعلامات الحاوية: حالة أقل تُمرَّر يدويًا، ومنطق أكثر ينبع مباشرة من البنية.
المزالق والممارسات الجيدة
- خصوصية (specificity)
:has()هي خصوصية أكثر المحدِّدات تحديدًا في داخلها، لا صفرًا. تمتلك.field:has(input:invalid)خصوصية صنفين وصنف زائف مجتمعين – لا تعتمد على أن:has()«لا تُحتسب» في الخصوصية، فهي عمليًا تتفوق بانتظام على قواعد أبسط مكتوبة لاحقًا في ورقة الأنماط. - استخدام
:has()بشكل واسع وعميق التداخل دون أداة ربط قد يكون مكلفًا. نظريًا، تُلزم.app:has(.some-deeply-nested-element)المحرك بفحص شجرة.appالفرعية بأكملها عند كل تغيير في DOM داخلها. تُحسِّن المحركات الحديثة (Chromium وWebKit) هذا عبر ما يُعرف بمجموعات الإبطال (invalidation sets) – فلا تُعيد حساب كل شيء عشوائيًا – لكن الأثر الفعلي يعتمد على المحدِّد بالتحديد وحجم الشجرة. ضيّق العلاقة بأداة ربط (>، الابن المباشر) حيثما أمكن، بدلًا من البحث الافتراضي في العمق. :has()لا تحلّ محل:focus-within. إذا كنت تحتاج فقط «الأصل يتفاعل عندما يحصل السليل على التركيز (focus)»، فإن:focus-withinموجودة منذ زمن طويل، وأرخص في الحساب، وأوضح للقراءة – ستمنحك:has(:focus)النتيجة نفسها عمليًا، لكن دون سبب للجوء إلى أداة أعمّ حيث توجد أداة متخصصة أصلًا.- دعم المتصفحات لم يعد مشكلة. حلّت
:has()كآخر قطعة كبيرة في الأحجية – امتلكها Safari منذ الإصدار 15.4 (مارس 2022)، وChrome منذ الإصدار 105 (أغسطس 2022)، وانضم إليها Firefox أخيرًا في الإصدار 121 (ديسمبر 2023). منذ ذلك الحين، أصبح استخدامها آمنًا دون@supportsفي أي مشروع جديد.
خلاصة
طوال 25 عامًا، لم يسمح CSS إلا بوصف علاقات موجّهة في اتجاه واحد – من السياق إلى الهدف، من الأصل إلى الابن، من السابق إلى اللاحق. لا تُعدّ :has() نسخة أخرى من الفكرة نفسها، بل أول صنف زائف يسمح للعنصر بأن يسأل عن داخله، ويتفاعل مع ما يجده هناك – خطأ تحقق في حقل، أو وجود صورة في بطاقة، أو غياب عناصر في قائمة. هذا ينقل فئة كاملة من القرارات، التي كانت سابقًا يجب أن تذهب إلى جافاسكريبت أو إلى خصائص تُمرَّر يدويًا من الأعلى، إلى حيث ينبغي أن تكون: بنية HTML وقواعد CSS التي تصف تلك البنية.