CSS :has() — العنصر الأب الذي يعرف ما يجري بالداخل
CSS :has() — العنصر الأب الذي يعرف ما يجري بالداخل
لديك حقل نموذج: <div class="field"> يحوي تسمية (label) وحقل إدخال بداخله. المتطلَّب يبدو بسيطًا – حين يكون الإدخال غير صالح، ينبغي أن يحصل الحاوية بأكملها على إطار أحمر وأيقونة تحذير بجانب التسمية، لا الإدخال وحده. تلجأ إلى .field input:invalid – فتصطدم بجدار. هذا المحدِّد كان سيُصمِّم الإدخال نفسه لو أراد إطارًا أحمر. لكنك تريد تصميم .field، أي العنصر الأب لذلك الإدخال، بناءً على ما يجري في داخله. وفجأة تكتشف أنّ CSS – رغم عشرات أدوات الربط والأصناف الزائفة (pseudo-classes) ومحدِّدات السمات – لم يوفّر يومًا، طوال تاريخه الممتدّ 25 عامًا، وسيلة لفعل ذلك.
هذا ليس ثغرة في معرفتك بـCSS. إنها سمة جوهرية في بنية المحدِّدات ظلّت سائدة منذ CSS1 وحتى عام 2022.
لماذا تنظر أداة الربط دائمًا في اتجاه واحد فقط
كلّ أداة ربط في CSS – المسافة (السليل)، و> (الابن المباشر)، و+ (الشقيق المجاور)، و~ (الشقيق العام) – تصف علاقة بين محدِّدَين، لكن يُصمَّم دائمًا العنصر الواقع على يمين أداة الربط. .field input يُصمِّم الإدخال داخل .field. .field ~ .error يُصمِّم .error، وهو شقيق لـ.field. الاتجاه ثابت دائمًا: من السياق إلى الهدف، وليس العكس أبدًا. محرّك CSS، حين يصادف .field، لا يملك آلية مدمجة «للنظر إلى الداخل» وتغيير قرار تصميم .field نفسها بناءً على ذلك.
تحايل المطوّرون على هذا لسنوات بطريقتين، كلتاهما بكلفة حقيقية. الأولى: جافاسكريبت يستمع إلى حدث input/blur، ويضيف يدويًا صنف .field--invalid إلى العنصر الأب. الثانية، أذكى لكنها هشّة – ما يُعرف بحيلة خانة الاختيار/زرّ الاختيار (checkbox/radio hack)، التي تستخدم ~ لتصميم الأشقاء بناءً على حالة :checked، وهذا لا يعمل إلا إذا كانت العناصر فعليًّا أشقاءً في بنية DOM مسطّحة، لا متداخلة كما يتطلّب نموذج حقيقي.
jsx
// حلّ بديل من قبل :has() – يعمل، لكنه يتطلّب JS لأمر// هو في جوهره نتيجة بصرية بحتة لحالة 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;}/* عنصر يليه مباشرة .field-hint كشقيق له */.field:has(+ .field-hint) {margin-bottom: 4px;}
هذا يجعل من :has() ليست حيلة جديدة واحدة، بل تعميمًا شاملًا – تسمح بالتعبير في 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() للمكوّن بنفسه أن يتعرّف على محتواه الخاص ويستجيب له، بدلًا من أن يُخبَر به من الخارج – هذا بالضبط اتجاه التفكير نفسه في بطاقة المنتج الواعية بحاويتها من مقال Container Queries: حالة أقلّ تُمرَّر يدويًا، ومنطق أكثر ينبع مباشرة من البنية.
الفخاخ والممارسات الجيدة
- خصوصية (specificity) :has() هي خصوصية أخصّ محدِّد بداخلها، لا صفر.
.field:has(input:invalid)تحمل خصوصية صنفين وصنف زائف معًا – لا تعتمد على أنّ:has()«لا تُحتسَب» في الخصوصية، لأنها عمليًّا تتغلّب باستمرار على قواعد أبسط منها مكتوبة لاحقًا في ملف الأنماط. - :has() العميقة والواسعة النطاق دون أداة ربط يمكن أن تُكلِّف.
.app:has(.some-deeply-nested-element)يُلزم المحرّك نظريًّا بأن يأخذ بعين الاعتبار شجرة.appالفرعية بأكملها عند كل تغيير في DOM بداخلها. المحرّكات الحديثة (Chromium، WebKit) تُحسِّن هذا عبر ما يُعرف بمجموعات الإبطال (invalidation sets) – فهي لا تعيد الحساب كليًّا بشكل أعمى – لكنّ الأثر يعتمد على المحدِّد المحدَّد وحجم الشجرة. ضيِّق العلاقة بأداة ربط (>، الابن المباشر) حيثما أمكن، بدلًا من البحث الافتراضي في العمق. - :has() لا تحلّ محلّ :focus-within. إن كنت تحتاج فقط «العنصر الأب يستجيب حين يحصل السليل على التركيز»، فـ
:focus-withinموجودة منذ زمن، وأرخص في الحساب، وأوضح في القراءة –:has(:focus)ستعطي عمليًّا الأثر نفسه، لكن دون سبب للجوء إلى أداة أعمّ حيث توجد بالفعل أداة متخصّصة. - دعم المتصفحات لم يعد مشكلة. جاءت
:has()بوصفها آخر قطعة كبيرة في الأحجية – حصل عليها Safari منذ الإصدار 15.4 (مارس 2022)، وChrome منذ الإصدار 105 (أغسطس 2022)، والتحق Firefox أخيرًا في الإصدار 121 (ديسمبر 2023). منذ ذلك الحين، أصبح استخدامها آمنًا دون@supportsفي أيّ مشروع جديد.
الخلاصة
طوال 25 عامًا، لم يسمح CSS إلّا بوصف علاقات موجَّهة في اتجاه واحد – من السياق إلى الهدف، من الأب إلى الابن، من السابق إلى اللاحق. :has() ليست نسخة بديلة أخرى من الفكرة نفسها، بل أوّل صنف زائف يسمح للعنصر بأن يسأل عن محتواه الداخلي ويستجيب لما يجده هناك – خطأ تحقّق في حقل، أو وجود صورة في بطاقة، أو غياب عناصر في قائمة. هذا ينقل فئة كاملة من القرارات، التي كان يجب أن تذهب سابقًا إلى جافاسكريبت أو إلى خصائص تُمرَّر يدويًا من الأعلى، إلى حيث تنتمي فعلًا: إلى بنية HTML وقواعد CSS التي تصف تلك البنية.