css og layouts

CSS :has() — forælderen, der ved, hvad der sker indeni

CSS :has() — forælderen, der ved, hvad der sker indeni

Du har et formularfelt: <div class="field"> med en label og et input indeni. Kravet lyder banalt – når inputtet er ugyldigt, skal hele containeren have en rød kant og et advarselsikon ved siden af labelen, ikke kun selve inputtet. Du griber til .field input:invalid – og rammer en mur. Den selektor ville style inputtet, hvis det skulle have en rød kant. Men du vil style .field, altså forælderen til det input, baseret på, hvad der sker i dens indre. Og pludselig viser det sig, at CSS – på trods af snesevis af kombinatorer, pseudoklasser og attributselektorer – aldrig i sin 25-årige historie har givet en måde at gøre det på.

Det er ikke en mangel i din CSS-viden. Det er et grundlæggende træk ved selektor-arkitekturen, som gjaldt fra CSS1 helt frem til 2022.

Hvorfor en kombinator altid kun kigger én vej

Enhver kombinator i CSS – mellemrum (efterkommer), > (direkte barn), + (nabo-søskende), ~ (generel søskende) – beskriver en relation mellem to selektorer, men det er altid elementet til højre for kombinatoren, der bliver stylet. .field input styler inputtet inde i .field. .field ~ .error styler .error, som er søskende til .field. Retningen er altid den samme: fra kontekst til mål, aldrig omvendt. CSS-motoren har, når den støder på .field, ingen indbygget mekanisme til at "kigge indeni" og på det grundlag ændre beslutningen om, hvordan .field selv skal stylet.

Udviklere har i årevis omgået det på to måder, begge med reelle omkostninger. Den første – JavaScript, der lytter efter input/blur-hændelsen og manuelt tilføjer klassen .field--invalid til forælderen. Den anden, mere snedig, men skrøbelig – det såkaldte checkbox/radio-hack, der bruger ~ til at style søskende baseret på :checked-tilstanden, hvilket kun virker, når elementerne faktisk er søskende i en flad DOM-struktur, og ikke indlejret, sådan som en rigtig formular kræver det.

jsx

// Løsningen fra før :has() – virker, men kræver JS til noget,
// der er en ren visuel konsekvens af HTML-tilstanden
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() eliminerer den kode, ikke fordi den er et smartere trick – men fordi den løser problemet ved kilden, som den første relationelle pseudoklasse i CSS' historie, der lader et element spørge til sit eget indre.

Sådan virker :has() egentlig: en selektor forankret i elementet, ikke i efterkommeren

Det centrale skifte i tankegangen: .field:has(input:invalid) styler ikke inputtet. Den styler .field – præcis det element, pseudoklassen er hæftet på – forudsat at der et sted i dets indre findes et element, der matcher selektoren inde i parentesen. Det element, du "forankrer" på (det såkaldte anchor element), forbliver altid det samme; :has() afgør blot, om det overhovedet får et match.

css

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

Som standard leder selektoren inde i :has() efter en vilkårlig efterkommer, i vilkårlig dybde – ligesom et almindeligt mellemrum. Men du kan indsnævre relationen på præcis samme måde som i almindelig CSS, ved at bruge kombinatorer direkte inde i parentesen:

css

/* Kun et direkte barn, ikke en vilkårlig efterkommer */
.card:has(> img) {
grid-template-columns: 120px 1fr;
}
/* Elementet, der UMIDDELBART efterfølges af .field-hint som søskende */
.field:has(+ .field-hint) {
margin-bottom: 4px;
}

Det gør, at :has() ikke er endnu et nyt trick, men en generalisering – den lader dig udtrykke i CSS enhver relation, du tidligere kunne beskrive med en kombinator, blot rettet "indad" eller "bagud" i stedet for udelukkende "fremad".

Praktisk eksempel: et formularfelt, der selv ved, at det er ugyldigt

Lad os samle det til en komplet, tilgængelig komponent. En vigtig detalje: :invalid udløses i sig selv straks, når et tomt felt med attributten required indlæses – før brugeren overhovedet har nået at skrive noget, hvilket ville give en rød kant på en tom, urørt formular. Det løses med :not(:placeholder-shown), som kun matcher feltet, når brugeren rent faktisk allerede har efterladt noget i det.

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;
}
/* Feltet er "rørt" (der er skrevet noget) OG ugyldigt – reager først da */
.field:has(input:not(:placeholder-shown):invalid) {
border-color: #c62828;
background: #fff5f5;
}
.field:has(input:not(:placeholder-shown):invalid) .field__icon {
visibility: visible;
}
/* Positiv bekræftelse er lige så vigtig som en fejl */
.field:has(input:not(:placeholder-shown):valid) {
border-color: #2e7d32;
}

Ikke en eneste linje JavaScript er ansvarlig for udseendet. Valideringstilstanden findes allerede nativt i browseren – :valid/:invalid har eksisteret siden CSS3 – :has() gjorde det bare endelig muligt at flytte den eksisterende tilstand fra inputtet til dets container, dér hvor den visuelt altid har været nødvendig.

Det andet mønster: en komponent, der ved, hvad den indeholder

Den samme mekanisme løser en helt anden klasse af problemer – layout, der afhænger af tilstedeværelsen af bestemt indhold, uden en prop, der fortæller det på forhånd.

css

/* Et kort med billede får et tokolonnelayout, et kort uden billede får fuld tekstbredde */
.card:has(> img) {
display: grid;
grid-template-columns: 96px 1fr;
gap: 12px;
}
/* Sektionens overskrift får afstand til bunden kun, hvis sektionen faktisk har en undertitel */
.section:has(> .section__subtitle) .section__title {
margin-bottom: 4px;
}
/* En liste uden et eneste element viser en tom-tilstand i stedet for tomt rum */
.product-list:not(:has(li)) {
display: block;
}
.product-list:not(:has(li))::after {
content: "Ingen produkter opfylder kriterierne.";
color: #666;
}

Uden :has() ville hvert af disse tilfælde kræve enten en prop (hasImage, isEmpty), sat manuelt af den overordnede komponent, eller kontrol af array-længden i JSX'en og betinget rendering af en separat klasse. :has() lader komponenten selv genkende sit eget indhold og reagere, i stedet for at blive fortalt om det udefra – det er præcis den samme tankegang som det produktkort, der kender sin egen container, fra artiklen om Container Queries: mindre tilstand overført manuelt, mere logik, der følger direkte af strukturen.

Faldgruber og god praksis

  • Specificiteten af :has() er specificiteten af den mest specifikke selektor indeni, ikke nul. .field:has(input:invalid) har specificiteten af to klasser og en pseudoklasse tilsammen – regn ikke med, at :has() "ikke tæller" i specificiteten, for i praksis slår den regelmæssigt simplere regler skrevet senere i arket.
  • Dybt indlejrede, brede :has()-kald uden kombinator kan koste noget. .app:has(.some-deeply-nested-element) beder i teorien motoren om at overveje hele undertræet af .app ved enhver DOM-ændring i dets indre. Moderne motorer (Chromium, WebKit) optimerer dette gennem såkaldte invalidation sets – de genberegner ikke alt blindt – men effekten afhænger af den konkrete selektor og træets størrelse. Indsnævr relationen med en kombinator (>, direkte barn), hvor det er muligt, i stedet for standardsøgning i dybden.
  • :has() erstatter ikke :focus-within. Hvis du kun har brug for "forælderen reagerer, når en efterkommer har fokus", har :focus-within eksisteret længe, er billigere at beregne og mere læsbart – :has(:focus) giver praktisk talt den samme effekt, men uden grund til at gribe til det mere generelle værktøj, hvor det specialiserede allerede findes.
  • Browserunderstøttelse er ikke længere et problem. :has() var den sidste store brik i puslespillet – Safari havde den fra 15.4 (marts 2022), Chrome fra 105 (august 2022), Firefox kom med sidst, i version 121 (december 2023). Siden da er den sikker at bruge uden @supports i ethvert nyt projekt.

Opsummering

I 25 år tillod CSS kun at beskrive relationer rettet én vej – fra kontekst til mål, fra forælder til barn, fra forgænger til efterfølger. :has() er ikke endnu en variant af den samme idé, men den første pseudoklasse, der lader et element spørge til sit eget indre og reagere på, hvad det finder der – en valideringsfejl i et felt, tilstedeværelsen af et billede i et kort, fravær af elementer i en liste. Det flytter en hel klasse af beslutninger, der tidligere måtte havne i JavaScript eller i props sendt manuelt oppefra, tilbage dér, hvor de hører hjemme: i HTML-strukturen og de CSS-regler, der beskriver den struktur.