CSS ופריסות

CSS ‎:has() — הורה שיודע מה קורה בפנים

CSS ‎:has() — הורה שיודע מה קורה בפנים

יש לכם שדה טופס: <div class="field"> עם תווית ו-input בפנים. הדרישה נשמעת טריוויאלית – כשה-input לא תקין, כל המיכל אמור לקבל מסגרת אדומה ואייקון אזהרה ליד התווית, לא רק ה-input עצמו. אתם פונים ל-.field input:invalid – ונתקלים בקיר. הסלקטור הזה היה מעצב את ה-input, אילו רצה מסגרת אדומה. אבל אתם רוצים לעצב את .field, כלומר את ההורה של אותו input, על סמך מה שקורה בתוכו. ופתאום מתברר שב-CSS – למרות עשרות קומבינטורים, פסאודו-מחלקות וסלקטורי מאפיינים – מעולם, ב-25 שנות ההיסטוריה שלו, לא הייתה דרך לעשות את זה.

זו לא פספוס בידע שלכם על CSS. זו תכונה יסודית של הארכיטקטורה של סלקטורים, שהתקיימה מ-CSS1 ועד 2022.

למה קומבינטור תמיד מסתכל רק לכיוון אחד

כל קומבינטור ב-CSS – רווח (צאצא), > (ילד ישיר), + (אח סמוך), ~ (אח כללי) – מתאר יחס בין שני סלקטורים, אבל תמיד מעוצב האלמנט הכתוב אחרי הקומבינטור בסלקטור. .field input מעצב את ה-input שבתוך .field. .field ~ .error מעצב את .error, שהוא אח של .field. הכיוון תמיד אותו כיוון: מההקשר אל המטרה, לעולם לא הפוך. מנוע ה-CSS, כשהוא נתקל ב-.field, אין לו מנגנון מובנה "להציץ פנימה" ולשנות על סמך זה את ההחלטה על הסגנון של .field עצמו.

מפתחים במשך שנים עקפו את זה בשתי דרכים, לשתיהן מחיר אמיתי. הראשונה – JavaScript שמאזין לאירוע input/blur, ומוסיף ידנית מחלקת .field--invalid להורה. השנייה, פיקחית יותר אבל שברירית – טריק ה-checkbox/radio הידוע, שמנצל את ~ כדי לעצב אחים על סמך מצב :checked, מה שעובד רק כשהאלמנטים הם באמת אחים במבנה DOM שטוח, לא מקוננים כפי שטופס אמיתי דורש.

jsx

// עוקף מלפני ‎:has() – עובד, אבל דורש JS למשהו
// שהוא תוצאה ויזואלית טהורה של מצב ה-HTML
import { 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() מבטל את הקוד הזה, לא כי הוא טריק פיקח יותר – אלא כי הוא פותר את הבעיה במקור, כפסאודו-מחלקה יחסית הראשונה בהיסטוריה של CSS, שמאפשרת לאלמנט לשאול על התוך של עצמו.

איך :has() באמת עובד: סלקטור שמעוגן באלמנט, לא בצאצא

השינוי המרכזי במחשבה: .field:has(input:invalid) לא מעצב את ה-input. הוא מעצב את .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;
}

אין אפילו שורת JavaScript אחת האחראית על המראה. מצב האימות כבר קיים באופן טבעי בדפדפן – :valid/:invalid נמצאים שם מאז CSS3 – :has() פשוט אפשר סוף-סוף להעביר את המצב הקיים הזה מה-input אל המיכל שלו, בדיוק למקום שבו הוא היה דרוש ויזואלית מההתחלה.

דפוס שני: רכיב שיודע מה הוא מכיל

אותו מנגנון פותר סוג שונה לחלוטין של בעיות – פריסה שתלויה בנוכחות של תוכן מסוים, בלי 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(), כל אחד מהמקרים האלה היה דורש או prop (‏hasImage, isEmpty) שנקבע ידנית על ידי הרכיב ההורה, או בדיקה של אורך המערך ב-JSX ורינדור מותנה של מחלקה נפרדת. :has() מאפשר לרכיב בעצמו לזהות את התוכן שלו ולהגיב לכך, במקום להיות מיודע עליו מבחוץ – זה בדיוק אותו כיוון מחשבה כמו כרטיסיית המוצר המודעת למיכל שלה מהמאמר על Container Queries: פחות state שמועבר ידנית, יותר לוגיקה שנובעת ישירות מהמבנה.

מלכודות ושיטות עבודה טובות

  • הספציפיות של :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() הוא לא עוד וריאציה על אותו רעיון, אלא הפסאודו-מחלקה הראשונה שמאפשרת לאלמנט לשאול על התוך של עצמו ולהגיב למה שהוא מוצא שם – שגיאת אימות בשדה, נוכחות תמונה בכרטיסייה, היעדר אלמנטים ברשימה. זה מזיז שלמה קטגוריה שלמה של החלטות שקודם היו חייבות להגיע ל-JavaScript או ל-props שמועברים ידנית מלמעלה, חזרה למקום שבו הם שייכים: למבנה ה-HTML ולכללי ה-CSS שמתארים את המבנה הזה.