La Cascade

Rechercher

Utiliser :has() comme sélecteur de parent et bien plus

par Jen Simmons, 25 août 2022, css, pseudo-classes, article original paru le 18 août 2022 dans le blog de webkit

Comment appliquer un style à un élément en fonction de ce qui se passe à l'intérieur de cet élément : la révolution :has()


Depuis longtemps les développeurs front rêvent d'avoir un moyen d'appliquer le CSS à un élément en fonction de ce qui se passe à l'intérieur de cet élément.

Nous pourrions vouloir par exemple disposer un élément d'article d'une certaine façon s'il y a une image "héros" en haut, et d'une autre façon s'il n'y a pas d'image "héros". Ou peut-être voulons-nous appliquer des styles différents à un formulaire en fonction de l'état de l'un de ses champs de saisie. Que diriez-vous de donner à une barre latérale une couleur d'arrière-plan si un certain composant est présent dans cette barre latérale, et une couleur d'arrière-plan différente si ce composant n'est pas présent ? Les cas d'utilisation de ce type existent depuis longtemps et les développeurs Web ont approché à plusieurs reprises le groupe de travail CSS, les suppliant d'inventer un "sélecteur de parent".

Au cours des vingt dernières années, le groupe de travail CSS a discuté de cette possibilité à de très nombreuses reprises. Le besoin était clair et bien compris. Définir la syntaxe était une tâche réalisable. Mais trouver comment un moteur de navigateur pourrait traiter des patterns circulaires potentiellement très complexes et effectuer les calculs assez rapidement semblait impossible. Les premières versions d'un sélecteur de parent ont été ébauchées pour CSS3, mais elles ont été reportées. Finalement, la pseudo-classe :has() a été officiellement définie dans les sélecteurs CSS de niveau 4. Mais le fait de disposer d'une norme Web ne suffit pas à faire de :has() une réalité. Nous avions encore besoin d'une équipe chargée des navigateurs pour relever le défi très réel des performances. Pendant ce temps, les ordinateurs continuaient à devenir plus puissants et plus rapides chaque année.

En 2021, Igalia a commencé à plaider en faveur de :has() auprès des équipes d'ingénierie des navigateurs, en prototypant leurs idées et en documentant leurs conclusions concernant les performances. Le regain d'intérêt pour :has() a attiré l'attention des ingénieurs qui travaillent sur WebKit chez Apple. Nous avons commencé à implémenter la pseudo-classe, en réfléchissant aux possibilités d'amélioration des performances nécessaires pour que cela fonctionne. Nous avons discuté pour savoir s'il fallait commencer par une version plus rapide avec un champ d'application très limité, puis essayer de supprimer ces limites si possible... ou commencer par quelque chose qui n'avait aucune limite, et n'appliquer de restrictions que si nécessaire. Nous nous sommes lancés et avons implémenté la version la plus puissante. Nous avons développé un certain nombre de nouvelles optimisations de mise en cache et de filtrage spécifiques à :has, et nous avons exploité les stratégies d'optimisation avancées existantes de notre moteur CSS. Et notre approche a fonctionné, prouvant qu'après deux décennies d'attente, il est enfin possible d'implémenter un tel sélecteur avec des performances fantastiques, même en présence de grands arbres DOM et d'un grand nombre de sélecteurs :has().

L'équipe WebKit a livré :has() dans l'aperçu technologique 137 de Safari en décembre 2021, et dans Safari 15.4 le 14 mars 2022. Igalia a effectué le travail d'ingénierie pour implémenter :has() dans Chromium, qui sera livré dans Chrome 105 le 30 août 2022. On peut supposer que les autres navigateurs construits sur Chromium ne seront pas loin derrière. Mozilla travaille actuellement sur l'implémentation de Firefox.

Voyons donc, étape par étape, ce que les développeurs Web peuvent faire avec cet outil tant désiré. Il s'avère que la pseudo-classe :has() n'est pas seulement un "sélecteur de parent". Après des décennies d'impasses, ce sélecteur peut faire bien plus.

Les bases de :has() comme sélecteur de parent

Commençons par les bases. Imaginons que nous voulions appliquer un style à un élément <figure> en fonction du type de contenu de l'image. Parfois, notre figure n'englobe qu'une image.

<figure>
  <img src="flowers.jpg" alt="fleurs de printemps">
</figure>

Alors que d'autres fois, l'image comporte une légende.

<figure>
  <img src="chien.jpg" alt="chien noir souriant au soleil">
  <figcaption>Maggie adore être dehors sans laisse.</figcaption>
</figure>

Appliquons maintenant quelques styles à la figure qui ne s'appliqueront que s'il y a une figcaption à l'intérieur de la figure.

figure:has(figcaption) {
  background: white;
  padding: 0.6rem;
}

Ce sélecteur décrit exactement ce qu'il dit — tout élément figure ayant une figcaption sera sélectionné.

Voici la démo, si vous souhaitez modifier le code et voir ce qui se passe. Veillez à utiliser un navigateur qui prend en charge :has() — à ce jour, on parle de Safari.

Dans cette démo, je cible également tout élément <figure> qui contient un élément <pre> en utilisant figure:has(pre).

figure:has(pre) {
  background: rgb(252, 232, 255);
  border: 3px solid white;
  padding: 1rem;
}

Et j'utilise une requête de fonctionnalité de sélecteur (Feature Query) pour masquer un rappel sur la prise en charge par le navigateur lorsque le navigateur actuel prend en charge :has().

@supports selector(:has(img)) {
  small {
    display: none;
  }
}

L'at-rule @supports selector() est elle-même très bien prise en charge. Elle peut être incroyablement utile chaque fois que vous voulez utiliser une requête de fonctionnalité pour tester la prise en charge par le navigateur d'un sélecteur particulier.

Et enfin, dans cette première démo, j'écris également un sélecteur complexe à l'aide de la pseudo-classe :not(). Je veux appliquer display : flex à la figure, mais seulement si le seul contenu est une image. Flexbox fait alors en sorte que l'image s'étire pour remplir tout l'espace disponible.

J'utilise un sélecteur pour cibler tout élément <figure> qui n'a pour enfant aucun élément autre que <img>. Si la figure a une figcaption, pre, p ou un h1 — ou tout autre élément que img — le sélecteur ne s'applique pas.

figure:not(:has(:not(img))) {
  affichage: flex;
}

:has() est sacrément puissant !

Un exemple pratique utilisant :has() avec CSS Grid

Regardons une deuxième démo où j'ai utilisé :has() comme sélecteur de parent pour résoudre facilement un besoin très pratique.

J'ai plusieurs cards (cartes ou encarts) pour des article mis en page à l'aide de CSS Grid. Certaines cartes ne contiennent que des titres et du texte, tandis que d'autres ont également une image. Je veux que les cartes avec des images prennent plus de place sur la grille que celles sans image.

Je ne veux pas avoir à faire un travail supplémentaire pour que mon système de gestion de contenu applique une classe ou utilise JavaScript pour la mise en page. Je veux juste écrire un simple sélecteur en CSS qui indiquera au navigateur de faire en sorte que toute carte contenant une image occupera deux lignes et deux colonnes dans la grille.

Avec la pseudo-classe :has() c'est très simple :

article:has(img) {
  grid-column: span 2;
  grid-row: span 2;
}
voir :has() Demo #2 — Teaser cards de jensimmons dans CodePen

Ces deux premières démos utilisent des sélecteurs d'éléments simples datant des premiers jours de CSS, mais tous les sélecteurs peuvent être combinés avec :has(), y compris le sélecteur de classe, le sélecteur d'ID, le sélecteur d'attribut — et de puissants combinateurs, comme nous allons le voir maintenant.

Utiliser :has() avec le combinateur enfant

Tout d'abord, un bref rappel de la différence entre le combinateur descendant et le combinateur enfant (>). (👉🏾 NdT : pour un tour complet de la question, vous pouvez consulter l'article Combinateurs et pseudo-classes ici-même).

Le combinateur descendant existe depuis le tout début de CSS. Il s'écrit en mettant un espace entre deux sélecteurs simples :

a img {
  ...;
}

Cela cible tous les éléments img qui sont contenus dans un élément a, quelle que soit la distance entre le a et l'img dans l'arbre DOM HTML.

<a>
  <figure>
    <img
      src="photo.jpg"
      alt="n'oublie pas le texte alternatif"
      width="200"
      height="100"
    />
  </figure>
</a>

Le combinateur sécrit en mettant un > entre deux sélecteurs — ce qui indique au navigateur de cibler tout ce qui correspond au deuxième sélecteur, mais uniquement lorsque le deuxième sélecteur est un enfant direct du premier.

a > img {
  ...;
}

Par exemple, ce sélecteur cible tous les éléments img enveloppés par un élément a, mais uniquement lorsque l'img se trouve immédiatement après le a dans le HTML.

<a>
  <img
    src="photo.jpg"
    alt="n'oublie pas le texte alternatif"
    width="200"
    height="100"
  />
</a>

En gardant cela à l'esprit, examinons la différence entre les deux exemples suivants. Les deux sélectionnent l'élément a, plutôt que l'img, puisque nous utilisons :has().

a:has(img) {
  ...;
}
a:has(> img) {
  ...;
}

La première sélectionne tout élément a avec une img à l'intérieur — n'importe où dans la structure HTML. Alors que la seconde sélectionne un élément uniquement si l'img est un enfant direct de l'élément a.

Les deux peuvent être utiles ; ils accomplissent des choses différentes.

Il existe encore deux autres types de combinateurs — tous deux sont des frères et sœurs. Et c'est grâce à eux que :has() devient plus qu'un sélecteur de parent.

Utiliser :has() avec des combinateurs frères et sœurs

Passons en revue les deux sélecteurs de relations "fraternelles". Il y a le combinateur de next-siblings (+) et le combinateur subsequent-siblings (~) de frères et sœurs suivants.

Le combinateur next-sibling (+) sélectionne uniquement les paragraphes qui suivent directement un élément h2 :

h2 + p
<h2>Les titres</h2>
<p>
  Paragraphe qui est sélectionné par "h2 + p", car il se trouve directement
  après "h2".
</p>
.

Le combinateur subsequent-siblings (~) sélectionne tous les paragraphes qui viennent après un élément h2. Ils doivent être frères et sœurs, mais il peut y avoir un nombre quelconque d'autres éléments HTML entre les deux.

h2 ~ p
<h2>Les titres</h2>
<h3>Autre chose</h3>
<p>Paragraphe qui est sélectionné par "h2 ~ p".</p>
<p>Ce paragraphe est également sélectionné.</p>

Notez que h2 + p et h2 ~ p sélectionnent tous deux les éléments paragraphes, et non les titres h2. Comme d'autres sélecteurs (pensez à img), c'est le dernier élément listé qui est ciblé par le sélecteur. Mais que faire si nous voulons cibler le h2 ? Eh bien nous pouvons utiliser les combinateurs frères et sœurs avec :has().

Combien de fois avez-vous souhaité pouvoir ajuster les marges d'un titre en fonction de l'élément qui le suit ? C'est maintenant facile. Ce code nous permet de sélectionner tout h2 ayant un p immédiatement après lui.

h2:has(+ p) {
  margin-bottom: 0;
}

Incroyable.

Et si nous voulions faire cela pour les six éléments de titre, sans écrire six copies du sélecteur. Nous pouvons utiliser :is() pour simplifier notre code.

:is(h1, h2, h3, h4, h5, h6):has(+ p) {
  margin-bottom: 0;
}

Mais que faire si nous voulons écrire ce code pour plus d'éléments que de simples paragraphes ? Éliminons la marge inférieure de tous les titres lorsqu'ils sont suivis de paragraphes, de légendes, d'exemples de code et de listes :

:is(h1, h2, h3, h4, h5, h6):has(+ :is(p, figcaption, pre, dl, ul, ol)) {
  margin-bottom: 0;
}

La combinaison de :has() avec les combinateurs descendants, les combinateurs enfants (>), les combinateurs frères et sœurs suivants (+) et les combinateurs frères et sœurs subséquents (~) ouvre un monde de possibilités. Mais ce n'est encore que le début.

Styler les états de formulaire sans JS

Il y a beaucoup de pseudo-classes fantastiques qui peuvent être utilisées à l'intérieur de has:(). En fait, cela révolutionne ce que les pseudo-classes peuvent faire. Auparavant, les pseudo-classes n'étaient utilisées que pour styler un élément en fonction d'un état spécial, ou pour styler l'un de ses enfants. Maintenant, les pseudo-classes peuvent être utilisées pour capturer un état, sans JavaScript, et styler n'importe quoi dans le DOM en fonction de cet état.

Les champs de saisie des formulaires constituent un moyen puissant de capturer un tel état. Les pseudo-classes spécifiques aux formulaires incluent :autofill, :enabled, :disabled, :read-only, :read-write, :placeholder-shown, :default, :checked, :indeterminate, :valid, :invalid, :in-range, :out-of-range, :required et :optional.

Résolvons l'un des cas d'utilisation que j'ai décrits dans l'introduction — le besoin de longue date de styler une étiquette de formulaire en fonction de l'état du champ de saisie. Commençons par un formulaire de base.

<form>
  <div>
    <label for="name">Nom</label>
    <input type="text" id="name" />
  </div>
  <div>
    <label for="site">Site web</label>
    <input type="url" id="site" />
  </div>
  <div>
    <label for="email">Email</label>
    <input type="email" id="email" />
  </div>
</form>

J'aimerais appliquer un arrière-plan à l'ensemble du formulaire lorsque l'un des champs est en focus.

form:has(:focus-visible) {
  background: antiquewhite;
}

J'aurais pu utiliser form:focus-within à la place, mais cela se comporterait comme form:has(:focus). La pseudo-classe :focus applique toujours le CSS lorsqu'un champ est en focus. La pseudo-classe :focus-visible fournit un moyen fiable de styler un indicateur de focus uniquement lorsque le navigateur en dessinerait un nativement, en utilisant la même heuristique complexe que le navigateur utilise pour déterminer s'il faut ou non appliquer un anneau de focus.

Maintenant, imaginons que je veuille styler les autres champs, ceux qui ne sont pas en focus — en changeant la couleur du texte de leur étiquette et la couleur de la bordure de l'entrée. Avant :has(), cela nécessitait JavaScript. Maintenant, nous pouvons utiliser ce CSS.

form:has(:focus-visible) div:has(input:not(:focus-visible)) label {
  color: peru;
}
form:has(:focus-visible) div:has(input:not(:focus-visible)) input {
  border: 2px solid peru;
}

Que dit ce sélecteur ? Si l'un des contrôles de ce formulaire a le focus, et que l'élément d'entrée de ce contrôle de formulaire particulier n'a pas le focus, alors change la couleur du texte de cette étiquette en peru. Et change la bordure du champ de saisie pour qu'elle soit 2px solid peru.

Vous pouvez voir ce code en action dans la démo suivante en cliquant à l'intérieur d'un des champs de texte. L'arrière-plan du formulaire change, comme je l'ai décrit précédemment. Et les couleurs de l'étiquette et de la bordure de saisie des champs qui ne sont pas en focus changent également.

Dans cette même démo, j'aimerais aussi améliorer l'avertissement à l'utilisateur lorsqu'il y a une erreur dans la façon dont il a rempli le formulaire. Depuis longtemps déjà, nous pouvons facilement mettre un encadré rouge autour d'une entrée invalide avec ce CSS.

input:invalid {
  outline: 4px solid red;
  border: 2px solid red;
}

Maintenant, avec :has(), nous pouvons également rendre le texte de l'étiquette rouge :

div:has(input:invalid) label {
  color: red;
}

Vous pouvez voir le résultat en tapant quelque chose dans le champ du site Web ou de l'adresse électronique qui n'est pas une URL ou une adresse électronique entièrement formée. Les deux sont invalides et déclenchent donc une bordure rouge et une étiquette rouge, avec un "X".

Basculement en mode sombre sans JS

Et enfin, dans cette même démo, j'utilise une case à cocher pour permettre à l'utilisateur de basculer entre un thème clair et un thème foncé.

body:has(input[type='checkbox']:checked) {
  background: blue;
  --primary-color: white;
}

body:has(input[type='checkbox']:checked) form {
  border: 4px solid white;
}

body:has(input[type='checkbox']:checked) form:has(:focus-visible) {
  background: navy;
}

body:has(input[type='checkbox']:checked) input:focus-visible {
  outline: 4px solid lightsalmon;
}

J'ai stylé la case à cocher du mode sombre à l'aide de styles personnalisés, mais elle ressemble toujours à une case à cocher. Avec des styles plus complexes, je pourrais créer une bascule (toggle) en CSS.

De la même façon, je pourrais utiliser un menu de sélection pour proposer à un utilisateur plusieurs thèmes pour mon site.

body:has(option[value='pony']:checked) {
  --font-family: cursive;
  --text-color: #b10267;
  --body-background: #ee458e;
  --main-background: #f4b6d2;
}

Chaque fois qu'il y a une occasion d'utiliser CSS au lieu de JavaScript, je la saisis. Cela permet d'obtenir une expérience plus rapide et un site Web plus robuste. JavaScript peut faire des choses incroyables, et nous devrions l'utiliser quand c'est le bon outil pour le travail. Mais si nous pouvons obtenir le même résultat en utilisant uniquement HTML et CSS, c'est encore mieux.

Et plus encore

En regardant les autres pseudo-classes, il y en a tellement qui peuvent être combinées avec :has(). Imaginez les possibilités avec :nth-child, :nth-last-child, :first-child, :last-child, :only-child, :nth-of-type, :nth-last-of-type, :first-of-type, :last-of-type, :only-of-type. La toute nouvelle pseudo-classe :modal est déclenchée lorsqu'un dialogue est dans l'état ouvert. Avec :has(:modal), vous pouvez donner un style à n'importe quoi dans le DOM selon que le dialogue est ouvert ou fermé.

Cependant, toutes les pseudo-classes ne sont pas actuellement prises en charge dans :has() dans tous les navigateurs, alors testez votre code dans plusieurs navigateurs. Actuellement, les pseudo-classes dynamiques de médias ne fonctionnent pas, comme :playing, :paused, :muted, etc. Elles pourraient très bien fonctionner à l'avenir, alors si vous lisez ceci dans quelques temps, testez-les ! De plus, la prise en charge de l'invalidation des formulaires est actuellement absente dans certaines situations spécifiques, donc les changements d'état dynamiques de ces pseudo-classes peuvent ne pas être mis à jour avec :has().

Safari 16 ajoutera la prise en charge de :has(:target), ce qui ouvrira des possibilités intéressantes pour écrire du code qui recherche dans l'URL actuelle un fragment correspondant à l'ID d'un élément spécifique. Par exemple, si un utilisateur clique sur une table des matières en haut d'un document et descend jusqu'à la section de la page correspondant à ce lien, :target permet de styler ce contenu de manière unique, en se basant sur le fait que l'utilisateur a cliqué sur le lien pour y arriver. Et :has() ouvre la voie aux possibilités d'un tel style.

À noter : le groupe de travail CSS a décidé d'interdire tous les pseudo-éléments existants à l'intérieur de :has(). Par exemple, article:has(p::first-line) et ol:has(li::marker) ne fonctionneront pas. Idem pour ::before et ::after.

La révolution :has()

C'est une petite révolution dans la façon d'écrire les sélecteurs CSS, qui ouvre un monde de possibilités jusqu'alors impossibles ou souvent sans intérêt. On a l'impression que même si nous reconnaissons immédiatement l'utilité de :has(), nous n'avons aucune idée de ce qui est vraiment possible. Au cours des prochaines années, les personnes qui réalisent des démonstrations et se plongent dans ce que CSS peut faire trouveront des idées incroyables, étirant :has() jusqu'à ses limites.

Michelle Barker a créé une démo fantastique qui déclenche l'animation des tailles de pistes d'une grille css grâce à l'utilisation de :has() et des états de survol. Vous pouvez en savoir plus à ce sujet dans son article de blog. La prise en charge des pistes de grille animées sera disponible dans Safari 16. Vous pouvez essayer cette démo aujourd'hui dans Safari Technology Preview ou Safari 16 beta.

La partie la plus difficile de :has() sera d'ouvrir nos esprits à ses possibilités. Nous nous sommes tellement habitués aux limites que nous impose l'absence de sélecteur de parent. Nous devons maintenant briser ces habitudes.

C'est aussi une raison supplémentaire d'utiliser le CSS pur sucre, et de ne pas se limiter aux classes définies dans un framework. En écrivant votre propre CSS, personnalisé pour votre projet, vous pouvez tirer pleinement parti de toutes les puissantes capacités des navigateurs d'aujourd'hui.

À quoi allez-vous utiliser :has() ? En décembre dernier, j'ai demandé sur Twitter quels cas d'utilisation les gens pourraient avoir pour :has(), et j'ai reçu de nombreuses réponses avec des idées incroyables. J'ai hâte de voir les vôtres.

Voir la liste des articles de Jen Simmons traduits dans La Cascade.
Article original paru le 18 août 2022 dans le blog de webkit
Traduit avec l'aimable autorisation de le blog de webkit et de Jen Simmons.
Copyright le blog de webkit © 2022