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 למשהו// שהוא תוצאה ויזואלית טהורה של מצב ה-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() מבטל את הקוד הזה, לא כי הוא טריק פיקח יותר – אלא כי הוא פותר את הבעיה במקור, כפסאודו-מחלקה יחסית הראשונה בהיסטוריה של 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 שמתארים את המבנה הזה.