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.

Publié le 4 octobre 2023

Browser Support

  • Chrome: 118.
  • Edge: 118.
  • Firefox: 146.
  • Safari: 17.4.

Source

Lorsque vous écrivez des sélecteurs, vous pouvez vous retrouver tiraillé entre deux mondes. D'une part, vous devez être assez précis sur les éléments que vous sélectionnez. D'un autre côté, vous voulez que vos sélecteurs restent faciles à remplacer et ne soient pas étroitement liés à la structure du DOM.

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

  • Ce sélecteur a 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 sélecteur d'enfant direct, il est étroitement lié à la structure DOM. Si le balisage change, vous devez également modifier votre CSS.

Toutefois, vous ne devez pas non plus écrire simplement img comme sélecteur pour cet élément, car cela sélectionnerait tous les éléments d'image de votre page.

Il est souvent difficile de trouver le juste équilibre. Au fil des ans, certains développeurs ont trouvé des solutions et des méthodes de contournement pour vous aider dans ce type de situation. Exemple :

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

Mais que faire si vous n'avez besoin d'aucun de ces services ? Et si le CSS vous permettait d'être très précis sur les éléments que vous sélectionnez, sans vous obliger à écrire des sélecteurs très spécifiques ou étroitement lié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

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

Par exemple, pour cibler uniquement les éléments <img> dans le composant .card, vous définissez .card comme racine de portée de la règle @@scope.

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

La règle de style à portée limitée img { … } ne peut sélectionner que les éléments <img> qui sont dans le champ d'application de l'élément .card correspondant.

Pour empêcher la sélection des éléments <img> à l'intérieur de la zone de contenu de la fiche (.card__content), vous pouvez rendre le sélecteur img plus spécifique. Une autre façon de procéder consiste à utiliser le fait que la règle @@scope accepte également une limite de portée qui détermine la limite inférieure.

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

Cette règle de style à portée limité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 portée, avec une limite supérieure et une limite inférieure, est souvent appelé portée en forme de donut.

Sélecteur :scope

Par défaut, toutes les règles de style à portée limitée sont relatives à la racine de la portée. Il est également possible de cibler l'élément racine de portée 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 */
    }
}

Le préfixe :scope est ajouté de manière implicite aux sélecteurs dans les règles de style à portée limitée. Si vous le souhaitez, vous pouvez être explicite en ajoutant vous-même :scope. Vous pouvez également ajouter le sélecteur & au début, à partir de l'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 portée peut utiliser la pseudo-classe :scope pour exiger une relation spécifique avec la racine de portée :

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

Une limite de portée peut également faire référence à des éléments en dehors de sa racine de portée en utilisant :scope. Exemple :

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

Les règles de style à portée limitée ne peuvent pas s'échapper du 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 dans le champ d'application.

@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 notre exemple, 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, & est réécrit dans le sélecteur utilisé pour la racine de portée, enveloppé dans un sélecteur :is(). Au final, le navigateur utilisera :is(#sidebar, .card) img comme sélecteur pour effectuer la 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 en suivant les règles de spécificité de :is() : la spécificité de & est celle de son argument le plus spécifique.

Appliquée à 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). Si vous combinez cela avec la spécificité de img (qui est (0,0,1)), vous obtenez (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 dans le calcul de la spécificité, une autre différence entre :scope et & est que :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.

Vous pouvez donc utiliser & plusieurs fois. Cela contraste avec :scope, que vous ne pouvez utiliser qu'une seule fois, car vous ne pouvez pas faire correspondre une racine de portée à l'intérieur d'une racine de portée.

@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 les règles de style à l'élément parent englobant de l'élément <style> en ne spécifiant aucune racine de portée. 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 à portée limitée ne ciblent que les éléments à l'intérieur de div avec le nom de classe card__header, car ce 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 portée. Cette étape intervient 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 différentes racines de portée, la déclaration qui comporte le moins de sauts d'éléments frères ou générationnels entre la racine de portée et l'objet de la règle de style à portée limitée est prioritaire.

Cette nouvelle étape est utile lorsque vous imbriquez plusieurs variantes d'un composant. Prenons un 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 consultez ce petit extrait de balisage, le troisième lien sera white au lieu de black, même s'il s'agit d'un enfant d'un div auquel la classe .light est appliquée. Cela est dû au critère d'ordre d'apparition utilisé par la cascade pour déterminer le gagnant. Il constate que .dark a a été déclaré en dernier et qu'il gagnera donc selon la règle .light a.

Le critère de proximité de la portée permet 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 à portée limitée ont la même spécificité, le critère de proximité de portée entre en jeu. Il pondère les deux sélecteurs en fonction de leur proximité avec leur racine de portée. Pour ce troisième élément a, il n'y a qu'un seul saut vers la racine de portée .light, mais deux vers celle de .dark. Par conséquent, le sélecteur a dans .light l'emporte.

Isolation des sélecteurs, et non des styles

Sachez que @scope limite la couverture des sélecteurs. Elle n'offre pas d'isolation du style. Les propriétés qui sont héritées par les enfants le sont toujours, au-delà de la limite inférieure de @scope. La propriété color en est un exemple. Lorsque vous déclarez un élément à l'intérieur d'une portée de type donut, color hérite toujours des enfants à l'intérieur du trou du donut.

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

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