Limiter la portée de vos sélecteurs avec l'attribut CSS @scope at-rule

Découvrez comment utiliser @scope pour sélectionner des éléments uniquement dans un sous-arbre limité de votre DOM.

Navigateurs pris en charge

  • Chrome: 118.
  • Edge: 118.
  • Firefox: derrière un indicateur.
  • Safari: 17.4.

Source

L'art délicat d'écrire des sélecteurs CSS

Lorsque vous écrivez des sélecteurs, vous pouvez vous sentir tiraillé entre deux mondes. D'une part, vous devez être assez précis sur les éléments que vous sélectionnez. D'autre part, vous souhaitez que vos sélecteurs restent faciles à remplacer et qu'ils ne soient pas étroitement associés à la structure DOM.

Par exemple, si vous souhaitez sélectionner "l'image hero dans la zone de contenu du composant de fiche" (ce qui est une sélection d'éléments plutôt spécifique), vous ne souhaitez probablement pas écrire un sélecteur comme .card > .content > img.hero.

  • Ce sélecteur présente une spécificité assez élevée de (0,3,1), ce qui le rend difficile à remplacer à mesure que votre code se développe.
  • En s'appuyant sur le combinateur d'enfant direct, il est étroitement associé à la structure DOM. Si le balisage change, vous devez également modifier votre CSS.

Vous ne souhaitez pas non plus écrire uniquement img comme sélecteur pour cet élément, car cela sélectionnerait tous les éléments image de votre page.

Trouver le juste équilibre dans ce domaine est souvent un défi de taille. Au fil des ans, certains développeurs ont trouvé des solutions et des solutions de contournement pour vous aider dans ce genre de situations. Exemple :

  • Des méthodologies telles que BEM vous obligent à attribuer à cet élément une classe card__img card__img--hero pour réduire la spécificité tout en vous permettant d'être précis dans ce que vous sélectionnez.
  • Les solutions basées sur JavaScript telles que le CSS à portée ou les composants stylisés réécrivent tous vos sélecteurs en ajoutant des chaînes générées de manière aléatoire (sc-596d7e0e-4, par exemple) pour les empêcher de cibler des éléments situés de l'autre côté de votre page.
  • Certaines bibliothèques abolissent même complètement les sélecteurs et vous obligent à placer les déclencheurs de style directement dans le balisage lui-même.

Mais que faire si vous n'avez besoin d'aucun de ces éléments ? Que se passerait-il si CSS vous permettait d'être à la fois très précis sur les éléments que vous sélectionnez, sans avoir à écrire des sélecteurs très spécifiques ou étroitement associés à votre DOM ? C'est là qu'intervient @scope, qui vous permet de sélectionner des éléments uniquement dans un sous-arbre de votre DOM.

Présentation de @scope

Avec @scope, vous pouvez limiter la portée de vos sélecteurs. Pour ce faire, définissez la racine de champ d'application, qui détermine la limite supérieure du sous-arbre que vous souhaitez cibler. Avec une racine de champ d'application définie, les règles de style contenues (appelées règles de style de portée) ne peuvent sélectionner que dans ce sous-arbre limité du DOM.

Par exemple, pour ne cibler que les éléments <img> du composant .card, vous devez définir .card comme racine de portée de la règle at @scope.

@scope (.card) {
    img {
        border-color: green;
    }
}

La règle de style avec portée img { … } ne peut sélectionner que les éléments <img> qui sont dans la portée de l'élément .card correspondant.

Pour empêcher la sélection des éléments <img> dans la zone de contenu de la fiche (.card__content), vous pouvez rendre le sélecteur img plus spécifique. Vous pouvez également utiliser le fait que la règle at @scope accepte également une limite de champ d'application qui détermine la limite inférieure.

@scope (.card) to (.card__content) {
    img {
        border-color: green;
    }
}

Cette règle de style à portée ne cible que les éléments <img> placés entre les éléments .card et .card__content dans l'arborescence des ancêtres. Ce type de champ d'application (avec une limite supérieure et inférieure) est souvent appelé champ d'application en forme de beignet.

Sélecteur :scope

Par défaut, toutes les règles de style à portée sont relatives à la racine de la portée. Vous pouvez également cibler l'élément racine de champ d'application lui-même. Pour ce faire, utilisez le sélecteur :scope.

@scope (.card) {
    :scope {
        /* Selects the matched .card itself */
    }
    img {
       /* Selects img elements that are a child of .card */
    }
}

Les sélecteurs dans les règles de style à portée sont ajoutés de manière implicite à :scope. Si vous le souhaitez, vous pouvez être explicite à ce sujet en ajoutant :scope en préfixe. Vous pouvez également ajouter le sélecteur & en amont, à partir du imbrication CSS.

@scope (.card) {
    img {
       /* Selects img elements that are a child of .card */
    }
    :scope img {
        /* Also selects img elements that are a child of .card */
    }
    & img {
        /* Also selects img elements that are a child of .card */
    }
}

Une limite de champ d'application peut utiliser la pseudo-classe :scope pour exiger une relation spécifique avec la racine du champ d'application:

/* .content is only a limit when it is a direct child of the :scope */
@scope (.media-object) to (:scope > .content) { ... }

Une limite de champ d'application peut également faire référence à des éléments en dehors de leur racine de champ d'application à l'aide de :scope. Exemple :

/* .content is only a limit when the :scope is inside .sidebar */
@scope (.media-object) to (.sidebar :scope .content) { ... }

Notez que les règles de style à portée ne peuvent pas échapper au sous-arbre. Les sélections telles que :scope + p ne sont pas valides, car elles tentent de sélectionner des éléments qui ne sont pas concernés.

@scope et spécificité

Les sélecteurs que vous utilisez dans le prélude pour @scope n'affectent pas la spécificité des sélecteurs contenus. Dans l'exemple ci-dessous, la spécificité du sélecteur img est toujours (0,0,1).

@scope (#sidebar) {
    img { /* Specificity = (0,0,1) */
        …
    }
}

La spécificité de :scope est celle d'une pseudo-classe régulière, à savoir (0,1,0).

@scope (#sidebar) {
    :scope img { /* Specificity = (0,1,0) + (0,0,1) = (0,1,1) */
        …
    }
}

Dans l'exemple suivant, en interne, le & est réécrit en tant que sélecteur utilisé pour la racine de l'étendue, encapsulé dans un sélecteur :is(). En fin de compte, le navigateur utilisera :is(#sidebar, .card) img comme sélecteur pour effectuer la mise en correspondance. Ce processus est appelé désucrage.

@scope (#sidebar, .card) {
    & img { /* desugars to `:is(#sidebar, .card) img` */
        …
    }
}

Étant donné que & est désucré à l'aide de :is(), la spécificité de & est calculée conformément aux règles de spécificité de :is(): la spécificité de & est celle de son argument le plus spécifique.

Appliqué à cet exemple, la spécificité de :is(#sidebar, .card) est celle de son argument le plus spécifique, à savoir #sidebar, et devient donc (1,0,0). Combinez cela à la spécificité de img ((0,0,1)) pour obtenir (1,0,1) comme spécificité pour l'ensemble du sélecteur complexe.

@scope (#sidebar, .card) {
    & img { /* Specificity = (1,0,0) + (0,0,1) = (1,0,1) */
        …
    }
}

Différence entre :scope et & dans @scope

Outre les différences de calcul de la spécificité, :scope et & présentent une autre différence : :scope représente la racine de portée correspondante, tandis que & représente le sélecteur utilisé pour faire correspondre la racine de portée.

C'est pourquoi vous pouvez utiliser & plusieurs fois. Contrairement à :scope, que vous ne pouvez utiliser qu'une seule fois, vous ne pouvez pas faire correspondre une racine de champ d'application dans une racine de champ d'application.

@scope (.card) {
  & & { /* Selects a `.card` in the matched root .card */
  }
  :scope :scope { /* ❌ Does not work */
    …
  }
}

Champ d'application sans prélude

Lorsque vous écrivez des styles intégrés avec l'élément <style>, vous pouvez limiter le champ d'application des règles de style à l'élément parent englobant de l'élément <style> en ne spécifiant aucune racine de champ d'application. Pour ce faire, omettez le prélude de @scope.

<div class="card">
  <div class="card__header">
    <style>
      @scope {
        img {
          border-color: green;
        }
      }
    </style>
    <h1>Card Title</h1>
    <img src="…" height="32" class="hero">
  </div>
  <div class="card__content">
    <p><img src="…" height="32"></p>
  </div>
</div>

Dans l'exemple ci-dessus, les règles de portée ne ciblent que les éléments situés dans le div avec le nom de classe card__header, car div est l'élément parent de l'élément <style>.

@scope dans la cascade

Dans la cascade CSS, @scope ajoute également un nouveau critère: la proximité de la portée. Cette étape vient après la spécificité, mais avant l'ordre d'apparition.

Visualisation de la cascade CSS.

Conformément aux spécifications:

Lorsque vous comparez des déclarations qui apparaissent dans des règles de style avec des racines de champ d'application différentes, la déclaration avec le moins de sauts générationnels ou d'éléments frères entre la racine de champ d'application et l'objet de la règle de style avec champ d'application l'emporte.

Cette nouvelle étape est utile lorsque vous imbriquez plusieurs variantes d'un composant. Prenons cet exemple, qui n'utilise pas encore @scope:

<style>
    .light { background: #ccc; }
    .dark  { background: #333; }
    .light a { color: black; }
    .dark a { color: white; }
</style>
<div class="light">
    <p><a href="#">What color am I?</a></p>
    <div class="dark">
        <p><a href="#">What about me?</a></p>
        <div class="light">
            <p><a href="#">Am I the same as the first?</a></p>
        </div>
    </div>
</div>

Lorsque vous affichez ce petit extrait de balisage, le troisième lien est white au lieu de black, même s'il est un enfant d'un div auquel la classe .light est appliquée. Cela est dû au critère d'ordre d'apparition que la cascade utilise ici pour déterminer le gagnant. Il voit que .dark a a été déclaré en dernier, il l'emporte donc en vertu de la règle .light a.

Le critère de proximité de champ d'application permet désormais de résoudre ce problème:

@scope (.light) {
    :scope { background: #ccc; }
    a { color: black;}
}

@scope (.dark) {
    :scope { background: #333; }
    a { color: white; }
}

Étant donné que les deux sélecteurs a avec portée ont la même spécificité, le critère de proximité de la portée entre en action. Il pondère les deux sélecteurs en fonction de leur proximité avec leur racine de champ d'application. Pour ce troisième élément a, il n'y a qu'un seul saut vers la racine de champ d'application .light, mais deux vers celle de .dark. Par conséquent, le sélecteur a dans .light l'emporte.

Remarque finale: Isolation du sélecteur, et non de style

Notez que @scope limite la portée des sélecteurs, mais n'offre pas d'isolation de style. Les propriétés qui héritent des enfants hériteront toujours, au-delà de la limite inférieure de la @scope. La propriété color en est un exemple. Lorsque vous déclarez un élément dans le champ d'application d'un donut, color hérite toujours des enfants dans le trou du donut.

@scope (.card) to (.card__content) {
  :scope {
    color: hotpink;
  }
}

Dans l'exemple ci-dessus, l'élément .card__content et ses enfants ont une couleur hotpink, car ils héritent de la valeur de .card.

(Photo de couverture par rustam burkhanov sur Unsplash)