Les sélecteurs en Angular sont trop souvent sous exploités, et ça entraine énormément de critiques infondés… Je vous explique !

Note 1 : dans les sources en fin d'article vous trouverez un lien Stackblitz pour voir tous les exemples dans un projet Angular avec lequel vous pouvez expérimenter.

Note 2 : J'ai volontairement suivi le nouveau style guide d'Angular, donc ne soyez pas surpris de ne pas voir de .component.ts ou même de suffixe Component sur le nom de classe des composants.

Le classique custom element !

En général un composant Angular c'est ça :

@Component({
  selector: "app-baby",
  template: `<h1>👶 <ng-content /></h1>`,
})
export class Baby {}

On l'utilise comme ça :

@Component({
  selector: 'app-root',
  imports: [Baby],
  template: `
    <app-baby>Hello from {{ name }}!</app-baby>
  `,
})
export class App {
  name = 'Angular';
}

et le DOM qui est généré est comme ça :

<app-root ng-version="20.0.0">
    <app-baby>
        <h1>👶 Hello from Angular!</h1>
    </app-baby>
</app-root>

À priori je ne vous apprends rien ici, rien de bien compliqué, un composant tout simple qui va contenir un titre en projetant le contenu du composant <app-baby> dans le parent, dans le contenu du h1 dans un custom-element. (Phrase pas simple à lire…)

Sauf que c'est pas fou en vrai :

  • on a un custom element qui ne sert globalement à rien ici ;
  • ça peut nous embêter pour avoir un style global ;
  • il y a des cas où le code Html devient invalide parce qu'on introduit des custom elements ;

Et là souvent je vois pas mal de gens dire "c'est comme ça en Angular, on y peut rien…" ou même "c'est nul Angular avec tous ces customs elements !". Pour les premiers je répondrais "vous n'avez pas compris comment fonctionne le selector du composant", pour les seconds "les customs elements ça fait partie intégrante du standard pour qu'on les utilise !".

Surcharger les balises standards

Maintenant regardez ce composant :

@Component({
  selector: "h1",
  template: `🥷 <ng-content />`,
})
export class Ninja {}

Il ressemble au premier, mais il y a une grosse subtilité : le h1 n'est plus dans template mais dans selector.

À l'usage maintenant :

@Component({
  selector: "app-root",
  imports: [Baby, Ninja],
  template: `
    <app-baby>Hello from {{ name }}!</app-baby>
    <h1>Hello from {{ name }}!</h1>
  `,
})
export class App {
  name = "Angular";
}

et côté DOM :

<app-root ng-version="20.0.0">
    <app-baby>
        <h1>👶 Hello from Angular!</h1>
    </app-baby>
    <h1>🥷 Hello from Angular!</h1>
</app-root>

Pas de custom element pour le second titre. Visuellement rien de lié à Angular. On peut appliquer un style global sans souci.

Un peu plus de magie !

Bon pour le coup surcharger une balise HTML comme je l'ai fait c'est pas terrible… Mais y'a d'autres options !

En fait selector c'est une liste de sélecteur CSS. Donc si c'est valide CSS, ça fonctionnera.

Exemple :

@Component({
  selector: "h2, [app-wizard], .app-wizard, #app-wizard, [role=heading]",
  template: `🧙‍♂️ <ng-content />`,
})
export class Wizard {}

À l'usage maintenant :

@Component({
  selector: "app-root",
  imports: [Baby, Ninja, Wizard],
  template: `
    <app-baby>Hello from {{ name }}!</app-baby>
    <h1>Hello from {{ name }}!</h1>
    <h2>Hello from {{ name }}!</h2>
    <h3 app-wizard>Hello from {{ name }}!</h3>
    <h3 id="app-wizard">Hello from {{ name }}!</h3>
    <h3 class="app-wizard">Hello from {{ name }}!</h3>
    <h3 role="heading">Hello from {{ name }}!</h3>
  `,
})
export class App {
  name = "Angular";
}

Côté DOM :

<app-root ng-version="20.0.0">
    <app-baby>
        <h1>👶 Hello from Angular!</h1>
    </app-baby>
    <h1>🥷 Hello from Angular!</h1>
    <h2>🧙‍♂️ Hello from Angular!</h2>
    <h3 app-wizard="">🧙‍♂️ Hello from Angular!</h3>
    <h3 id="app-wizard">🧙‍♂️ Hello from Angular!</h3>
    <h3 class="app-wizard">🧙‍♂️ Hello from Angular!</h3>
    <h3 role="heading">🧙‍♂️ Hello from Angular!</h3>
</app-root>

Comme vous pouvez le voir, on peut mettre un peu ce qu'on veut comme sélecteur, on peut en mettre autant qu'on veut (il suffit de les séparer par des virgules) et on peut être créatif !

Attention : une balise ne peut être la cible que d'un seul composant à la fois. Concrètement <h1 app-wizard>...</h1> ça ne fonctionnera pas, vous aurez une erreur, car les composants Ninja et Wizard s'appliquent simultanément.

Quelques exemples de la vraie vie

Liste sous forme de composants

Le genre de chose que je vois parfois c'est ça :

@Component({
  selector: "app-list-item",
  template: `<li><ng-content /></li>`,
})
export class ListItem {}

@Component({
  selector: "app-list-demo",
  imports: [ListItem],
  template: `
    <ul>
      <app-list-item>One</app-list-item>
      <app-list-item>Two</app-list-item>
      <app-list-item>Three</app-list-item>
      <app-list-item>Four</app-list-item>
    </ul>
  `,
})
export class ListDemo {}

Ce qui donne un DOM comme ça :

<app-list-demo>
    <ul class="list-group">
        <app-list-item>
            <li class="list-group-item">One</li>
        </app-list-item>
        <app-list-item>
            <li class="list-group-item">Two</li>
        </app-list-item>
        <app-list-item>
            <li class="list-group-item">Three</li>
        </app-list-item>
        <app-list-item>
            <li class="list-group-item">Four</li>
        </app-list-item>
    </ul>
</app-list-demo>

Dans la majorité des cas, ça va fonctionner sauf que ce code n'est pas valide selon la spécification HTML, donc le navigateur travaille plus pour l'interpréter (donc est plus lent), et vous n'avez aucune garantie que ça fonctionnera partout et/ou dans le futur.

Donc comment on fait ? On passe par un attribut !

@Component({
  selector: "[app-list-item]",
  template: `<ng-content />`,
  host: {
    class: "list-group-item",
  },
})
export class ListItem2 {}

@Component({
  selector: "app-list-demo2",
  imports: [ListItem2],
  template: `
    <ul class="list-group">
      <li app-list-item>One</li>
      <li app-list-item>Two</li>
      <li app-list-item>Three</li>
      <li app-list-item>Four</li>
    </ul>
  `,
})
export class ListDemo2 {}

On utilise le host sur l'annotation @Component pour remettre la classe CSS qu'on avait à la base. On peut faire beaucoup de chose avec le host, je vous laisse explorer ça !

Ce qui donne un DOM comme ça :

<app-list-demo2>
    <ul class="list-group">
        <li app-list-item="" class="list-group-item">One</li>
        <li app-list-item="" class="list-group-item">Two</li>
        <li app-list-item="" class="list-group-item">Three</li>
        <li app-list-item="" class="list-group-item">Four</li>
    </ul>
</app-list-demo2>

Ici c'est bien mieux !

Éclater un tableau en composants

J'ai travaillé à une époque sur des applications de finance. Qui dit finance, dit aussi énormément de donnée, énorme tableau, impossible à maintenir sur un seul composant…

Donc on tente des choses comme ça :

interface Transaction {
  id: string;
  label: string;
  amount: number;
}

@Component({
  selector: "app-table-headers",
  template: ` <thead>
    <tr>
      <th>Label</th>
      <th>Amount</th>
    </tr>
  </thead>`,
})
export class TableHeaders {}

@Component({
  selector: "app-table-row",
  template: ` <tr>
    <td>{{ data()?.label }}</td>
    <td>{{ data()?.amount }}</td>
  </tr>`,
})
export class TableRow {
  data = input<Transaction>();
}

@Component({
  selector: "app-table",
  imports: [TableHeaders, TableRow],
  template: `
    <table>
      <app-table-headers />
      <tbody>
        @for (transaction of data(); track transaction.id) {
        <app-table-row [data]="transaction" />
        }
      </tbody>
    </table>
  `,
})
export class Table {
  data = input<Transaction[]>();
}

Sauf que le DOM qui résulte n'est pas valide et ne fonctionne pas correctement :

<app-table>
    <table>
        <app-table-headers>
            <thead>
                <tr>
                    <th>Label</th>
                    <th>Amount</th>
                </tr>
            </thead>
        </app-table-headers>
        <tbody>
            <app-table-row>
                <tr>
                    <td>one</td>
                    <td>100000000</td>
                </tr>
            </app-table-row>
            <app-table-row>
                <tr>
                    <td>two</td>
                    <td>1340000</td>
                </tr>
            </app-table-row>
            <app-table-row>
                <tr>
                    <td>three</td>
                    <td>1009877500</td>
                </tr>
            </app-table-row>
            <app-table-row>
                <tr>
                    <td>four</td>
                    <td>98475800000</td>
                </tr>
            </app-table-row>
        </tbody>
    </table>
</app-table>
Capture du rendu du code juste au-dessus qui ne fonctionne pas

Un élément <table> ne peut contenir que des éléments <thead> et <tbody>, qui eux-mêmes ne peuvent que contenir des éléments <tr>, et eux-mêmes que des éléments <td> ou <th>. Ici on a des éléments intermédiaires qui empêchent le comportement et style normal du navigateur.

Comment on fait du coup ? On passe par des attributs ! Ou des sélecteurs en surcharge des éléments Html comme on le sent. Ici comme on doit passer des données, le mieux c'est de passer par des attributs pour garder le code clair et concis.

@Component({
  selector: "[app-transactions-table-headers]",
  template: `<tr>
    <th>Label</th>
    <th>Amount</th>
  </tr>`,
})
export class TransactionsTableHeaders {}

@Component({
  selector: "[app-transactions-table-row]",
  template: `<td>{{ data()?.label }}</td>
    <td>{{ data()?.amount }}</td>`,
})
export class TransactionsTableRow {
  data = input<Transaction>(undefined, { alias: "app-transactions-table-row" });
}

@Component({
  selector: "app-transactions-table",
  imports: [TransactionsTableHeaders, TransactionsTableRow],
  template: `
    <table>
      <thead app-transactions-table-headers></thead>
      <tbody>
        @for (transaction of data(); track transaction.id) {
        <tr [app-transactions-table-row]="transaction"></tr>
        }
      </tbody>
    </table>
  `,
})
export class TransactionsTable {
  data = input<Transaction[]>();
}

J'en ai profité pour prendre des noms plus fonctionnel et lié au contexte. J'ai aussi fait le choix de réutiliser l'attribut app-transactions-table-row qui sert à cibler le composant pour passer l'objet transaction (ce n'est pas obligatoire), j'ai trouvé ça plus élégant.

Capture du rendu du code juste au-dessus qui ce coup-ci fonctionne

Empêcher de cliquer sur une checkbox en aria-disabled=true

Depuis le début je ne parle que du sélecteur d'un composant, mais on peut faire la même chose avec les directives aussi !

Imaginons quelque chose de plutôt simple : on veut s'assurer que les attributs aria-disabled et disabled soient correctement synchronisés pour gérer correctement l'accessibilité. On veut que si on définit aria-disabled=true alors on a l'attribut disabled, sinon non.

Une solution c'est de passer par une directive qui cible directement aria-disabled plutôt qu'un attribut dédié à la directive pour appliquer automatiquement le comportement :

@Directive({
  selector: "[aria-disabled]",
  host: {
    "[disabled]": "ariaDisabled()",
    "[attr.aria-disabled]": "ariaDisabled()",
  },
})
export class AriaDisabledCheckbox {
  ariaDisabled = input(false, {
    alias: "aria-disabled",
    transform: (value) => Boolean(value),
  });
}

Pour l'utiliser :

@Component({
  selector: "app-root",
  imports: [AriaDisabledCheckbox],
  template: `
    <label>
        <input type="checkbox" [aria-disabled]="false" />
        [aria-disabled]="false"
    </label>
    <label>
        <input type="checkbox" [aria-disabled]="true" />
        [aria-disabled]="true"
    </label>
  `,
})
export class App {}

Côté DOM on aura ça :

<label>
    <input type="checkbox" aria-disabled="false">
    [aria-disabled]="false"
</label>
<label>
    <input type="checkbox" disabled="" aria-disabled="true">
    [aria-disabled]="true"
</label>

Conclusion

On pourrait prendre beaucoup d'exemples. On peut faire des choses très complexes (pas forcément compliqué pour autant) avec les sélecteurs. On peut surtout faire des choses proprement alors que les gens vont préférer des solutions alambiquées par méconnaissance du framework Angular.

Prenez le temps de comprendre comment fonctionne Angular plutôt que de rester sur comment vous pensez qu'il fonctionne, vous verrez qu'Angular est un framework beaucoup plus permissif que vous ne le pensez !

Source :

Crédit photo : Générée via Mistral AI avec le prompt suivant :

Imaginez une scène de super-héros où un développeur, vêtu d'une cape aux couleurs d'Angular, utilise des sélecteurs Angular comme des super-pouvoirs pour transformer et manipuler des éléments HTML. Autour de lui, des éléments HTML s'animent et se transforment en composants dynamiques et réactifs, illustrant la puissance et la flexibilité des sélecteurs Angular. Les sélecteurs sont représentés comme des éclairs de lumière qui modifient les éléments HTML en temps réel, montrant comment Angular peut rendre le développement web plus puissant et efficace. En arrière-plan, des lignes de code Angular brillent, symbolisant la magie derrière ces transformations.