css ja asettelu

CSS :has() — vanhempi, joka tietää mitä sisällä tapahtuu

CSS :has() — vanhempi, joka tietää mitä sisällä tapahtuu

Sinulla on lomakekenttä: <div class="field">, jonka sisällä on selite ja input. Vaatimus kuulostaa triviaalilta – kun input on virheellinen, koko säiliön pitäisi saada punainen reunaviiva ja varoituskuvake selitteen viereen, ei vain itse inputin. Tartut selektoriin .field input:invalid – ja törmäät seinään. Tämä selektori tyylittäisi inputin, jos se haluaisi punaisen reunaviivan. Mutta sinä haluat tyylittää .field-elementin, eli tämän inputin vanhemman, sen perusteella, mitä sen sisällä tapahtuu. Ja yllättäen käy ilmi, että CSS – kymmenistä kombinaattoreista, pseudoluokista ja attribuuttiselektoreista huolimatta – ei koko 25-vuotisen historiansa aikana ole koskaan tarjonnut tapaa tehdä tätä.

Tämä ei ole aukko CSS-osaamisessasi. Tämä on selektoriarkkitehtuurin perustavanlaatuinen ominaisuus, joka oli voimassa CSS1:stä vuoteen 2022 asti.

Miksi kombinaattori katsoo aina vain yhteen suuntaan

Jokainen CSS:n kombinaattori – välilyönti (jälkeläinen), > (suora lapsi), + (vieressä oleva sisarus), ~ (yleinen sisarus) – kuvaa suhteen kahden selektorin välillä, mutta tyylitettävä elementti on aina kombinaattorin oikealla puolella oleva. .field input tyylittää .field-elementin sisällä olevan inputin. .field ~ .error tyylittää .error-elementin, joka on .field:n sisarus. Suunta on aina sama: kontekstista kohteeseen, ei koskaan toisin päin. CSS-moottorilla ei ole .field-elementtiä kohdatessaan sisäänrakennettua mekanismia "kurkistaa sisään" ja muuttaa sen perusteella päätöstä itse .field-elementin tyylistä.

Kehittäjät kiersivät tämän vuosien ajan kahdella tavalla, molemmilla todellisin kustannuksin. Ensimmäinen – JavaScript, joka kuuntelee input/blur-tapahtumaa ja lisää käsin .field--invalid-luokan vanhemmalle. Toinen, ovelampi mutta hauraampi – niin kutsuttu checkbox/radio-hakkerointi, joka käyttää ~-kombinaattoria sisaruksen tyylittämiseen :checked-tilan perusteella, mikä toimii vain silloin, kun elementit ovat todella sisaruksia litteässä DOM-rakenteessa, eivätkä sisäkkäisiä, kuten todellinen lomake vaatii.

jsx

// Kiertotie ennen :has()-ominaisuutta – toimii, mutta vaatii JS:ää johonkin,
// mikä on puhtaasti HTML-tilan visuaalinen seuraus
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() eliminoi tämän koodin, ei siksi että se olisi ovelampi temppu – vaan siksi että se ratkaisee ongelman juuresta, CSS:n historian ensimmäisenä relaationaalisena pseudoluokkana, joka antaa elementin kysellä omaa sisäistään.

Miten :has() oikeasti toimii: elementtiin ankkuroitu selektori, ei jälkeläiseen

Ratkaiseva ajattelun muutos: .field:has(input:invalid) ei tyylitä inputia. Se tyylittää .field-elementin – juuri sen elementin, johon pseudoluokka on kiinnitetty – sillä ehdolla, että sen sisällä jossain on elementti, joka täsmää suluissa olevaan selektoriin. Elementti, johon "ankkuroidut" (engl. anchor element), pysyy aina samana; :has() vain päättää, saako se ylipäätään osuman.

css

.field:has(input:invalid) {
border-color: #c62828;
}
.field:has(input:invalid) .field__icon {
visibility: visible;
}

Oletuksena :has():n sisällä oleva selektori etsii mitä tahansa jälkeläistä, millä tahansa syvyydellä – aivan kuten tavallinen välilyönti. Mutta voit rajata suhdetta täsmälleen samalla tavalla kuin tavallisessa CSS:ssä, käyttämällä kombinaattoreita suoraan sulkujen sisällä:

css

/* Vain suora lapsi, ei mikä tahansa jälkeläinen */
.card:has(> img) {
grid-template-columns: 120px 1fr;
}
/* Elementti, jota VÄLITTÖMÄSTI seuraa .error-message sisaruksena */
.field:has(+ .field-hint) {
margin-bottom: 4px;
}

Tämä tekee :has():sta ei vain yhden uuden tempun, vaan yleistyksen – se antaa ilmaista CSS:ssä minkä tahansa suhteen, joka aiemmin oli kuvattavissa kombinaattorilla, mutta suunnattuna "sisäänpäin" tai "taaksepäin" pelkän "eteenpäin"-suunnan sijaan.

Käytännön esimerkki: lomakekenttä, joka tietää itse olevansa virheellinen

Yhdistetään tämä täydelliseksi, saavutettavaksi komponentiksi. Tärkeä yksityiskohta: :invalid itsessään käynnistyy heti, kun tyhjä required-attribuutilla varustettu kenttä latautuu – ennen kuin käyttäjä on ehtinyt kirjoittaa mitään, mikä antaisi punaisen reunaviivan tyhjälle, koskemattomalle lomakkeelle. Tämän ratkaisee :not(:placeholder-shown), joka täsmää kenttään vain silloin, kun käyttäjä on todella jo jättänyt siihen jotain.

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;
}
/* Kenttä on "kosketettu" (jotain kirjoitettu) JA virheellinen – vasta silloin reagoi */
.field:has(input:not(:placeholder-shown):invalid) {
border-color: #c62828;
background: #fff5f5;
}
.field:has(input:not(:placeholder-shown):invalid) .field__icon {
visibility: visible;
}
/* Positiivinen vahvistus on yhtä tärkeä kuin virhe */
.field:has(input:not(:placeholder-shown):valid) {
border-color: #2e7d32;
}

Ei ainuttakaan riviä JavaScriptia ulkoasusta vastaamassa. Validointitila on jo olemassa natiivisti selaimessa – :valid/:invalid ovat olleet siellä CSS3:sta lähtien – :has() vain viimein antoi siirtää tämän olemassa olevan tilan inputista sen säiliöön, sinne missä sitä on visuaalisesti tarvittu aina.

Toinen kaava: komponentti, joka tietää mitä se sisältää

Sama mekanismi ratkaisee täysin toisenlaisen ongelmaluokan – asettelun, joka riippuu tietyn sisällön olemassaolosta, ilman propsia, joka kertoisi siitä etukäteen.

css

/* Kuvallinen kortti saa kaksipalstaisen asettelun, kuvaton kortti – täysleveän tekstin */
.card:has(> img) {
display: grid;
grid-template-columns: 96px 1fr;
gap: 12px;
}
/* Osion otsikko saa alamarginaalin vain, jos osiolla on todella alaotsikko */
.section:has(> .section__subtitle) .section__title {
margin-bottom: 4px;
}
/* Lista ilman yhtäkään elementtiä näyttää tyhjän tilan pelkän tyhjän tilan sijaan */
.product-list:not(:has(li)) {
display: block;
}
.product-list:not(:has(li))::after {
content: "Ei kriteerit täyttäviä tuotteita.";
color: #666;
}

Ilman :has():ää jokainen näistä tapauksista vaatisi joko ylemmän komponentin käsin asettaman propsin (hasImage, isEmpty) tai taulukon pituuden tarkistamisen JSX:ssä ja erillisen luokan ehdollisen renderöinnin. :has() antaa komponentin itse tunnistaa oman sisältönsä ja reagoida siihen, sen sijaan että siitä kerrottaisiin ulkopuolelta – tämä on täsmälleen sama ajattelusuunta kuin Container Queries -artikkelin omasta säiliöstään tietoinen tuotekortti: vähemmän käsin välitettyä tilaa, enemmän suoraan rakenteesta seuraavaa logiikkaa.

Sudenkuopat ja hyvät käytännöt

  • :has():n spesifisyys on sen sisällä olevan spesifisimmän selektorin spesifisyys, ei nolla. .field:has(input:invalid):n spesifisyys on kahden luokan ja pseudoluokan yhteissumma – älä luota siihen, että :has() "ei laske" spesifisyyteen, koska käytännössä se ohittaa säännöllisesti yksinkertaisemmat, myöhemmin arkissa kirjoitetut säännöt.
  • Syvästi sisäkkäinen, laaja :has() ilman kombinaattoria voi maksaa. .app:has(.some-deeply-nested-element) käskee teoriassa moottoria harkitsemaan koko .app:n alipuuta joka kerta, kun sen sisällä tapahtuu DOM-muutos. Nykyaikaiset moottorit (Chromium, WebKit) optimoivat tätä niin kutsutuilla invalidation seteillä – ne eivät laske kaikkea sokkona uudelleen – mutta vaikutus riippuu tarkasta selektorista ja puun koosta. Rajaa suhdetta kombinaattorilla (>, suora lapsi) aina kun mahdollista, oletusarvoisen syvyyshaun sijaan.
  • :has() ei korvaa :focus-within:ää. Jos tarvitset vain "vanhempi reagoi, kun jälkeläisellä on fokus", :focus-within on ollut olemassa jo pitkään, se on halvempi laskea ja luettavampi – :has(:focus) antaa käytännössä saman lopputuloksen, mutta ilman syytä tarttua yleisempään työkaluun siellä, missä erikoistunut jo on olemassa.
  • Selaintuki lakkasi olemasta ongelma. :has() saapui palapelin viimeisenä suurena palana – Safarissa se on ollut versiosta 15.4 (maaliskuu 2022), Chromessa versiosta 105 (elokuu 2022), Firefox liittyi mukaan viimeisenä, versiossa 121 (joulukuu 2023). Siitä lähtien sitä on turvallista käyttää ilman @supports-sääntöä jokaisessa uudessa projektissa.

Yhteenveto

25 vuoden ajan CSS salli kuvata vain yhteen suuntaan kulkevia suhteita – kontekstista kohteeseen, vanhemmasta lapseen, edeltäjästä seuraajaan. :has() ei ole saman idean uusi muunnelma, vaan ensimmäinen pseudoluokka, joka antaa elementin kysellä omaa sisäistään ja reagoida siihen, mitä sieltä löytyy – validointivirhe kentässä, kuvan olemassaolo kortissa, elementtien puuttuminen listalta. Tämä siirtää kokonaisen päätösluokan, joka aiemmin joutui menemään JavaScriptiin tai käsin ylhäältä välitettyihin propseihin, takaisin sinne minne se kuuluu: HTML-rakenteeseen ja CSS-sääntöihin, jotka tuota rakennetta kuvaavat.