Avez-vous une question? (216) 97 656 803 hajjriadh@gmail.com
Angular

Directives d'attributs et directives structurelles

Angular distingue les composants, les directives d’attribut et les directives structurelles. Les liaisons [class] et [style] remplacent le plus souvent NgClass et NgStyle, et les blocs @if, @for et @s...

0 chapitres 0 QCM Accès gratuit
Dans cet article

À propos de ce cours

Angular distingue les composants, les directives d’attribut et les directives structurelles. Les liaisons [class] et [style] remplacent le plus souvent NgClass et NgStyle, et les blocs @if, @for et @switch remplacent *ngIf, *ngFor et [ngSwitch]. Une directive d’attribut se compose d’un sélecteur entre crochets, d’entrées, de sorties et de liaisons sur son hôte (host). Elle agit sur le DOM avec ElementRef et Renderer2. Une directive structurelle affiche son modèle grâce à TemplateRef et ViewContainerRef, et l’étoile * est un raccourci pour . ngTemplateOutlet rend un composant générique. hostDirectives compose des comportements, et exportAs nomme une directive. On teste une directive à travers un composant hôte. Le cours se termine par 7 activités corrigées et les erreurs fréquentes.
  1. Objectifs

    • Distinguer les trois familles de directives : composants, directives d'attribut, directives structurelles
    • Utiliser les directives intégrées (NgClass, NgStyle, NgTemplateOutlet) et savoir quand une simple liaison suffit
    • Passer des anciennes directives *ngIf, *ngFor, [ngSwitch] aux blocs @if, @for, @switch
    • Créer ses propres directives d'attribut : sélecteur, entrées, sorties, liaisons de l'hôte
    • Créer une directive structurelle avec TemplateRef et ViewContainerRef, et comprendre l'étoile *
    • Composer des directives (hostDirectives), les nommer (exportAs) et les tester
  1. Présentation

    • Une directive est une classe qui ajoute un comportement à un élément du template. Cette leçon présente les deux familles que l'on crée soi-même : les directives d'attribut, qui modifient l'élément qui les porte, et les directives structurelles, qui ajoutent ou retirent des éléments. Vous en utilisez depuis le début du cours : ngModel en est une, et chaque composant est une directive munie d'un template.
    • Écrire ses propres directives permet de réutiliser un comportement sur n'importe quel élément : surligner au survol, donner le focus, fermer un menu au clic extérieur, afficher un contenu selon un rôle… sans recopier le même code dans chaque composant.
  • Pour suivre ce chapitre, continuez dans le projet premiers-pas-angular et créez un dossier src/app/directives/. Tous les exemples ont été compilés et testés avec Angular 22.2 (composants OnPush par défaut, sans Zone.js).
  1. Qu'est-ce qu'une directive ?

    • Angular distingue trois familles :
      • les composants: des directives avec un template, utilisées comme une balise (<app-carte />) ;
      • les directives d'attribut: elles modifient l'apparence ou le comportement de l'élément qui les porte, appelé l'hôte (<p appSurligner>) ;
      • les directives structurelles: elles ajoutent ou retirent des éléments, en travaillant sur un <ng-template> (<p *appSauf="…">).
    • Comme un composant, une directive ne s'applique que si elle est importée dans le composant qui l'utilise (imports: [Surligner]).

Les trois familles de directives Angular

  1. Classes et styles : NgClass, NgStyle ou liaisons ?

    • NgClass et NgStyle sont des directives d'attribut intégrées. Depuis l'arrivée des liaisons [class] et [style] avec objet, elles ne sont presque plus nécessaires :
import { Component, signal } from '@angular/core';
import { NgClass, NgStyle } from '@angular/common';

// Directives d'attribut intégrées : NgClass et NgStyle, comparées aux liaisons [class] et [style]
@Component({
  selector: 'app-classes',
  imports: [NgClass, NgStyle],
  template: `
    <label><input type="checkbox" [checked]="urgent()" (change)="urgent.set(!urgent())" /> Urgent</label>
    <label>Taille : <input type="range" min="12" max="24" [value]="taille()" (input)="taille.set(+$any($event.target).value)" /></label>

    <p [ngClass]="{ urgent: urgent(), fait: false }" [ngStyle]="{ 'font-size.px': taille() }">Avec NgClass et NgStyle</p>
    <p [class.urgent]="urgent()" [style.font-size.px]="taille()">Avec [class.urgent] et [style.font-size.px]</p>
    <p [class]="{ urgent: urgent() }" [style]="{ 'font-size': taille() + 'px' }">Avec [class] et [style] (objets)</p>
  `,
  styles: `
    p { margin: 6px 0; padding: 6px 10px; border-radius: 6px; background: #f1f5f9; }
    .urgent { background: #fee2e2; color: #b91c1c; font-weight: 700; }
    label { margin-right: 16px; }
  `,
})
export class Classes {
  protected readonly urgent = signal(true);
  protected readonly taille = signal(16);
}

Résultat : trois écritures, le même rendu

  • Les trois paragraphes ont le même rendu. Les liaisons n'exigent aucun import ; NgClass et NgStyle doivent être importés depuis @angular/common.
  • Angular fournit deux migrations automatiques : ng generate @angular/core:ngclass-to-class et ng generate @angular/core:ngstyle-to-style.

Classes et styles : liaison ou directive

  1. Les directives structurelles historiques

    • Avant Angular 17, on affichait conditionnellement avec *ngIf, on répétait avec *ngFor et on choisissait avec [ngSwitch]. Ces directives sont dépréciées depuis Angular 20: on les rencontre encore dans les projets existants, mais un nouveau code utilise les blocs.
import { Component, signal } from '@angular/core';
import { NgFor, NgIf, NgSwitch, NgSwitchCase, NgSwitchDefault } from '@angular/common';

// Les directives structurelles historiques (dépréciées depuis Angular 20) et leurs blocs équivalents
@Component({
  selector: 'app-ancienne-syntaxe',
  imports: [NgIf, NgFor, NgSwitch, NgSwitchCase, NgSwitchDefault],
  template: `
    <button (click)="vider()">Vider la liste</button>
    <button (click)="etat.set(etat() === 'ouvert' ? 'ferme' : 'ouvert')">Changer l'état</button>

    <div class="cols">
      <div>
        <h5>Directives (*ngIf, *ngFor, [ngSwitch])</h5>
        <ul *ngIf="taches().length > 0; else vide">
          <li *ngFor="let t of taches(); let i = index">{{ i + 1 }}. {{ t }}</li>
        </ul>
        <ng-template #vide><p>Aucune tâche.</p></ng-template>
        <p [ngSwitch]="etat()">
          <span *ngSwitchCase="'ouvert'">Inscriptions ouvertes</span>
          <span *ngSwitchDefault>Inscriptions fermées</span>
        </p>
      </div>
      <div>
        <h5>Blocs (&#64;if, &#64;for, &#64;switch)</h5>
        @if (taches().length > 0) {
          <ul>
            @for (t of taches(); track $index; let i = $index) {
              <li>{{ i + 1 }}. {{ t }}</li>
            }
          </ul>
        } @else {
          <p>Aucune tâche.</p>
        }
        <p>
          @switch (etat()) {
            @case ('ouvert') { <span>Inscriptions ouvertes</span> }
            @default { <span>Inscriptions fermées</span> }
          }
        </p>
      </div>
    </div>
  `,
  styles: `.cols { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } h5 { margin: 6px 0; } ul { margin: 4px 0; padding-left: 20px; }`,
})
export class AncienneSyntaxe {
  protected readonly taches = signal(['Lire le cours', 'Faire le TP']);
  protected readonly etat = signal<'ouvert' | 'ferme'>('ouvert');
  protected vider() {
    this.taches.set([]);
  }
}

Résultat : les deux écritures produisent le même affichage

  • Les deux colonnes affichent la même chose. Les directives exigent un import (NgIf, NgFor…), les blocs non.
  • Oublier l'import de NgIf ne bloque pas la compilation, mais la console affiche : NG0303: Can't bind to 'ngIf' since it isn't a known property of 'p', et le paragraphe ne s'affiche jamais.
  • Pour convertir tout un projet : ng generate @angular/core:control-flow.

Des directives structurelles aux blocs de contrôle

  1. Créer une directive d'attribut

    • Le CLI crée le fichier, avec un sélecteur d'attribut (entre crochets) préfixé par app:
$ ng generate directive directives/surligner
CREATE src/app/directives/surligner.spec.ts (212 bytes)
CREATE src/app/directives/surligner.ts (123 bytes)
import { Directive } from '@angular/core';

@Directive({
  selector: '[appSurligner]',
})
export class Surligner {}
  • On la complète : une entrée qui porte le même nom que le sélecteur permet d'écrire la couleur directement sur l'attribut, et la propriété host écoute la souris et lie le style de l'hôte :
import { Component, Directive, input, signal } from '@angular/core';

// Une directive d'attribut : elle modifie l'apparence de l'élément qui la porte (l'hôte)
@Directive({
  selector: '[appSurligner]',
  host: {
    '(mouseenter)': 'survol.set(true)',
    '(mouseleave)': 'survol.set(false)',
    '[style.backgroundColor]': 'survol() ? (appSurligner() || couleurParDefaut()) : null',
    '[style.transition]': '"background-color .2s"',
  },
})
export class Surligner {
  // Même nom que le sélecteur : on écrit appSurligner="…" directement sur l'élément
  readonly appSurligner = input('');
  readonly couleurParDefaut = input('#fde68a');
  protected readonly survol = signal(false);
}

@Component({
  selector: 'app-demo-surligner',
  imports: [Surligner],
  template: `
    <p appSurligner>Survolez-moi : couleur par défaut.</p>
    <p appSurligner="#bae6fd">Survolez-moi : bleu clair.</p>
    <p [appSurligner]="couleur()" couleurParDefaut="#bbf7d0">Survolez-moi : couleur choisie ci-dessous.</p>
    <select (change)="couleur.set($any($event.target).value)">
      <option value="">(par défaut : vert)</option>
      <option value="#fbcfe8">Rose</option>
      <option value="#ddd6fe">Violet</option>
    </select>
  `,
  styles: `p { padding: 6px 10px; border-radius: 6px; margin: 4px 0; }`,
})
export class DemoSurligner {
  protected readonly couleur = signal('');
}

Résultat : le troisième paragraphe survolé, en rose

  • <p appSurligner>: l'entrée vaut '', la couleur par défaut s'applique. appSurligner="#bae6fd" transmet un texte fixe ; [appSurligner]="couleur()" une expression.
  • couleurParDefaut est une seconde entrée : sur l'hôte, elle s'écrit comme un attribut ordinaire.
  • Sans l'import, <p appSurligner="#bae6fd"> compile et ne fait rien (c'est un simple attribut HTML) ; [appSurligner]="…" est refusé : NG8002: Can't bind to 'appSurligner' since it isn't a known property of 'p'.

L'anatomie d'une directive d'attribut

  1. Accéder à l'élément hôte

    • Une directive peut obtenir l'élément qui la porte avec inject(ElementRef). Exemple : donner le focus à un champ dès qu'il apparaît.
import { Component, Directive, ElementRef, afterNextRender, inject, signal } from '@angular/core';

// Accéder à l'élément hôte : inject(ElementRef), puis agir après le premier affichage
@Directive({ selector: '[appFocusAuto]' })
export class FocusAuto {
  private readonly hote = inject<ElementRef<HTMLElement>>(ElementRef);

  constructor() {
    afterNextRender(() => this.hote.nativeElement.focus());
  }
}

@Component({
  selector: 'app-demo-focus-auto',
  imports: [FocusAuto],
  template: `
    <button (click)="ouvert.set(!ouvert())">{{ ouvert() ? 'Fermer' : 'Rechercher' }}</button>
    @if (ouvert()) {
      <input appFocusAuto placeholder="Le curseur est déjà ici" />
    }
  `,
})
export class DemoFocusAuto {
  protected readonly ouvert = signal(false);
}

Résultat : le champ apparaît avec le curseur

  • afterNextRender() attend que l'élément soit réellement affiché : appeler focus() directement dans le constructeur serait trop tôt.
  • Pour lire l'élément, ElementRef suffit. Pour créer ou modifier des éléments, préférez Renderer2 (activité 2) : il fonctionne aussi hors du navigateur (rendu côté serveur).
  • La propriété host remplace les anciens décorateurs @HostListener et @HostBinding: c'est l'écriture recommandée.

Les liaisons de l'hôte

  1. Une directive qui émet : l'appui long

    • Une directive peut aussi avoir des sorties. Celle-ci émet quand on maintient l'appui assez longtemps, ce qui évite une boîte de confirmation pour une suppression :
import { Component, Directive, input, numberAttribute, output, signal } from '@angular/core';

// Une directive avec une sortie : elle émet quand on maintient l'appui assez longtemps
@Directive({
  selector: '[appAppuiLong]',
  host: {
    '(pointerdown)': 'commencer()',
    '(pointerup)': 'annuler()',
    '(pointerleave)': 'annuler()',
    '[class.appui-en-cours]': 'enCours()',
  },
})
export class AppuiLong {
  readonly duree = input(800, { transform: numberAttribute });
  readonly appAppuiLong = output<void>();
  protected readonly enCours = signal(false);
  private minuterie?: ReturnType<typeof setTimeout>;

  protected commencer() {
    this.enCours.set(true);
    this.minuterie = setTimeout(() => {
      this.enCours.set(false);
      this.appAppuiLong.emit();
    }, this.duree());
  }

  protected annuler() {
    clearTimeout(this.minuterie);
    this.enCours.set(false);
  }
}

@Component({
  selector: 'app-demo-appui-long',
  imports: [AppuiLong],
  template: `
    @for (f of fichiers(); track f) {
      <div class="ligne">
        {{ f }}
        <button duree="700" (appAppuiLong)="supprimer(f)">Maintenir pour supprimer</button>
      </div>
    } @empty {
      <p>Corbeille vide.</p>
    }
  `,
  styles: `
    .ligne { display: flex; justify-content: space-between; gap: 10px; padding: 4px 0; }
    button.appui-en-cours { background: #fecaca; outline: 3px solid #ef4444; }
  `,
})
export class DemoAppuiLong {
  protected readonly fichiers = signal(['brouillon.txt', 'ancien-logo.png', 'notes.md']);
  protected supprimer(f: string) {
    this.fichiers.update((l) => l.filter((x) => x !== f));
  }
}

Résultat : appui en cours sur le deuxième bouton

  • La sortie porte le nom du sélecteur : on écrit (appAppuiLong)="supprimer(f)", comme un événement natif.
  • duree="700" est un texte : numberAttribute le convertit en nombre.
  • pointerdown et pointerup fonctionnent à la souris comme au doigt ; pointerleave annule si le pointeur quitte le bouton.
  1. Créer une directive structurelle

    • Une directive structurelle reçoit le modèle (TemplateRef) et l'emplacement où l'afficher (ViewContainerRef). Elle décide combien de fois l'afficher : zéro, une, ou plusieurs.
    • appSauf est l'inverse de @if: elle affiche son contenu sauf si la condition est vraie, avec un modèle de remplacement facultatif :
import { Component, Directive, TemplateRef, ViewContainerRef, effect, inject, input, signal } from '@angular/core';

// Une directive structurelle : elle décide si son modèle (<ng-template>) est affiché
@Directive({ selector: '[appSauf]' })
export class Sauf {
  private readonly modele = inject(TemplateRef);
  private readonly conteneur = inject(ViewContainerRef);

  readonly appSauf = input.required<boolean>();
  readonly appSaufSinon = input<TemplateRef<unknown> | null>(null);

  constructor() {
    effect(() => {
      this.conteneur.clear();
      if (!this.appSauf()) {
        this.conteneur.createEmbeddedView(this.modele);
      } else {
        const sinon = this.appSaufSinon();
        if (sinon) {
          this.conteneur.createEmbeddedView(sinon);
        }
      }
    });
  }
}

@Component({
  selector: 'app-demo-sauf',
  imports: [Sauf],
  template: `
    <button (click)="connecte.set(!connecte())">{{ connecte() ? 'Se déconnecter' : 'Se connecter' }}</button>

    <p *appSauf="connecte(); sinon: bienvenue">Connectez-vous pour voir vos notes.</p>
    <ng-template #bienvenue><p>Bienvenue, Amel : vous avez 3 nouvelles notes.</p></ng-template>

    <!-- La forme longue, sans l'étoile -->
    <ng-template [appSauf]="connecte()"><p class="discret">(forme longue) Accès invité</p></ng-template>
  `,
  styles: `.discret { color: #64748b; font-size: 13px; }`,
})
export class DemoSauf {
  protected readonly connecte = signal(false);
}

Résultat : après la connexion, le modèle « sinon »

  • L'effect relit les entrées : à chaque changement de connecte(), il vide l'emplacement puis crée la vue voulue.
  • Dans *appSauf="connecte(); sinon: bienvenue", la clé sinon est préfixée par le sélecteur : elle alimente l'entrée appSaufSinon.
  • Utilisée sans étoile sur un élément ordinaire (<p [appSauf]="…">), la directive ne trouve pas de modèle : NG0201: No provider for TemplateRef found.
  • Une seule directive structurelle par élément : NG5002: Can't have multiple template bindings on one element. Use only one attribute prefixed with *. Pour en combiner deux, placez la première sur un <ng-container>.

Ce que cache l'étoile *

TemplateRef et ViewContainerRef

  1. ng-template, ng-container et ngTemplateOutlet

    • <ng-template> définit un modèle qui n'est pas affiché tant qu'une directive ne le demande pas. <ng-container> regroupe sans ajouter de balise. NgTemplateOutlet affiche un modèle en lui passant un contexte.
    • Ensemble, ils permettent un composant générique dont le parent choisit l'affichage de chaque ligne :
import { Component, TemplateRef, input, signal } from '@angular/core';
import { NgTemplateOutlet } from '@angular/common';

interface Cours { titre: string; heures: number; niveau: 'débutant' | 'avancé'; }

// Un composant générique : le parent fournit le modèle de chaque ligne
@Component({
  selector: 'app-liste-generique',
  imports: [NgTemplateOutlet],
  template: `
    <ul>
      @for (e of elements(); track $index) {
        <li>
          <ng-container *ngTemplateOutlet="modele() ?? defaut; context: { $implicit: e, rang: $index + 1 }" />
        </li>
      }
    </ul>
    <ng-template #defaut let-e>{{ e }}</ng-template>
  `,
  styles: `ul { margin: 4px 0; padding-left: 18px; }`,
})
export class ListeGenerique<T> {
  readonly elements = input.required<readonly T[]>();
  readonly modele = input<TemplateRef<{ $implicit: T; rang: number }> | null>(null);
}

@Component({
  selector: 'app-demo-modeles',
  imports: [ListeGenerique, NgTemplateOutlet],
  template: `
    <h5>Sans modèle</h5>
    <app-liste-generique [elements]="noms" />

    <h5>Avec un modèle fourni par le parent</h5>
    <app-liste-generique [elements]="cours()" [modele]="ligneCours" />
    <ng-template #ligneCours let-c let-rang="rang">
      <strong>{{ rang }}. {{ c.titre }}</strong> · {{ c.heures }} h
      <ng-container *ngTemplateOutlet="badge; context: { $implicit: c.niveau }" />
    </ng-template>

    <ng-template #badge let-n><span class="badge" [class.avance]="n === 'avancé'">{{ n }}</span></ng-template>
  `,
  styles: `.badge { font-size: 11px; padding: 1px 8px; border-radius: 10px; background: #dcfce7; } .avance { background: #fee2e2; } h5 { margin: 6px 0 2px; }`,
})
export class DemoModeles {
  protected readonly noms = ['Amel', 'Karim', 'Sana'];
  protected readonly cours = signal<Cours[]>([
    { titre: 'Angular', heures: 40, niveau: 'débutant' },
    { titre: 'Services et DI', heures: 12, niveau: 'avancé' },
  ]);
}

Résultat : la même liste, sans et avec modèle

  • let-c lit $implicit; let-rang="rang" lit la clé rang du contexte.
  • modele() ?? defaut: sans modèle fourni, le composant utilise le sien.
  • C'est ainsi que fonctionnent les bibliothèques de tableaux ou de listes : le composant gère la structure, le parent le contenu.

ng-template, ng-container et ngTemplateOutlet

  1. Composer des directives : hostDirectives

    • Un composant peut appliquer lui-même des directives à son élément hôte, et exposer leurs entrées et sorties sous le nom de son choix :
import { Component, signal } from '@angular/core';
import { AppuiLong } from './appui-long';
import { Surligner } from './surligner';

// La composition de directives : le composant applique lui-même deux directives à son hôte
@Component({
  selector: 'app-bouton-danger',
  hostDirectives: [
    { directive: Surligner, inputs: ['appSurligner: couleurSurvol'] },
    { directive: AppuiLong, inputs: ['duree'], outputs: ['appAppuiLong: confirme'] },
  ],
  host: { role: 'button', tabindex: '0' },
  template: `<ng-content />`,
  styles: `:host { display: inline-block; padding: 6px 14px; border: 2px solid #dc2626; color: #b91c1c; border-radius: 8px; cursor: pointer; user-select: none; }
           :host(.appui-en-cours) { outline: 3px solid #ef4444; }`,
})
export class BoutonDanger {}

@Component({
  selector: 'app-demo-composition',
  imports: [BoutonDanger],
  template: `
    <app-bouton-danger couleurSurvol="#fee2e2" duree="600" (confirme)="compte.set(compte() + 1)">
      Maintenir pour vider la corbeille
    </app-bouton-danger>
    <p>Corbeille vidée {{ compte() }} fois.</p>
  `,
})
export class DemoComposition {
  protected readonly compte = signal(0);
}

Résultat : le bouton surligné, après un appui long

  • BoutonDanger n'écrit aucune logique : il assemble Surligner et AppuiLong.
  • Seules les entrées et sorties listées sont accessibles. [appSurligner] sur <app-bouton-danger> est refusé : NG8002: Can't bind to 'appSurligner' since it isn't a known property of 'app-bouton-danger'. On écrit couleurSurvol.

La composition de directives avec hostDirectives

  1. exportAs et dialogue avec une directive parente

    • exportAs donne un nom à la directive : le template peut la désigner avec une variable de référence et appeler ses méthodes publiques. Une directive peut aussi injecter celle d'un élément parent:
import { Component, Directive, inject, input, signal } from '@angular/core';

// exportAs : le template peut nommer la directive et lire ses membres publics
@Directive({
  selector: '[appCompteurClics]',
  exportAs: 'compteur',
  host: { '(click)': 'nombre.set(nombre() + 1)' },
})
export class CompteurClics {
  readonly nombre = signal(0);
  remettreAZero() {
    this.nombre.set(0);
  }
}

// Une directive qui injecte la directive de son parent : l'accordéon garde un seul panneau ouvert
@Directive({ selector: '[appAccordeon]' })
export class Accordeon {
  readonly ouvert = signal<string | null>(null);
}

@Directive({
  selector: '[appPanneau]',
  host: { '[class.ouvert]': 'estOuvert()', '(click)': 'basculer()' },
})
export class Panneau {
  private readonly accordeon = inject(Accordeon);
  readonly appPanneau = input.required<string>();
  estOuvert() {
    return this.accordeon.ouvert() === this.appPanneau();
  }
  basculer() {
    this.accordeon.ouvert.set(this.estOuvert() ? null : this.appPanneau());
  }
}

@Component({
  selector: 'app-demo-export-parent',
  imports: [CompteurClics, Accordeon, Panneau],
  template: `
    <button appCompteurClics #c="compteur">Cliquez-moi</button>
    <span> {{ c.nombre() }} clic(s) </span>
    <button (click)="c.remettreAZero()">Remettre à zéro</button>

    <div appAccordeon class="acc">
      <div appPanneau="html"><b>HTML</b><p>La structure de la page.</p></div>
      <div appPanneau="css"><b>CSS</b><p>La mise en forme.</p></div>
      <div appPanneau="ts"><b>TypeScript</b><p>La logique, typée.</p></div>
    </div>
  `,
  styles: `
    .acc { margin-top: 10px; border: 1px solid #cbd5e1; border-radius: 8px; }
    .acc > div { padding: 6px 10px; border-bottom: 1px solid #e2e8f0; cursor: pointer; }
    .acc p { display: none; margin: 4px 0 0; color: #475569; }
    .acc .ouvert { background: #eff6ff; } .acc .ouvert p { display: block; }
  `,
})
export class DemoExportParent {}

Résultat : 3 clics comptés, le panneau CSS ouvert

  • #c="compteur" désigne la directive, pas le bouton : c.nombre() et c.remettreAZero() sont accessibles.
  • Un nom inconnu est refusé : NG8003: No directive found with exportAs 'compteurs'.
  • inject(Accordeon) remonte l'arbre des éléments : chaque panneau trouve l'accordéon qui l'entoure, et un seul panneau reste ouvert.

exportAs et directive parente

  1. Tester une directive

    • On teste une directive à travers un petit composant hôte, écrit dans le fichier de test :
import { Component, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Surligner } from './surligner';
import { Sauf } from './sauf';

@Component({
  imports: [Surligner, Sauf],
  template: `
    <p appSurligner="rgb(186, 230, 253)">Texte</p>
    <span *appSauf="cache()">Visible</span>
  `,
})
class Hote {
  readonly cache = signal(false);
}

describe('Directives', () => {
  it('Surligner colore l\'hôte au survol, puis le rétablit', async () => {
    const f = TestBed.createComponent(Hote);
    await f.whenStable();
    const p: HTMLElement = f.nativeElement.querySelector('p');
    p.dispatchEvent(new Event('mouseenter'));
    await f.whenStable();
    expect(p.style.backgroundColor).toBe('rgb(186, 230, 253)');
    p.dispatchEvent(new Event('mouseleave'));
    await f.whenStable();
    expect(p.style.backgroundColor).toBe('');
  });

  it('Sauf retire son modèle quand la condition devient vraie', async () => {
    const f = TestBed.createComponent(Hote);
    await f.whenStable();
    expect(f.nativeElement.querySelector('span')?.textContent).toBe('Visible');
    f.componentInstance.cache.set(true);
    await f.whenStable();
    expect(f.nativeElement.querySelector('span')).toBeNull();
  });
});
$ ng test --include src/app/directives/surligner.spec.ts
 ✓ Directives > Surligner colore l'hôte au survol, puis le rétablit
 ✓ Directives > Sauf retire son modèle quand la condition devient vraie

      Tests  2 passed (2)
  • Piège : avec un champ ordinaire (cache = false) dans le composant hôte, le second test échoue. Les composants sont OnPush: modifier le champ ne prévient pas Angular. Un signal le fait.
  • Le test fourni par ng generate (new Surligner()) ne vérifie que la création : il échoue même dès que la directive utilise inject(), appelé hors d'un contexte d'injection.

Aide-mémoire : les directives Angular

  1. Activité 1 : une bordure configurable

    • Créer la directive appBordure avec trois entrées : la couleur (même nom que le sélecteur, gris par défaut), epaisseur (nombre, 2 par défaut, avec numberAttribute) et arrondi (booléen, avec booleanAttribute).
    • Elle lie border, border-radius (12 px si arrondi) et padding de l'hôte. L'appliquer à trois cartes produits : standard, promotion (vert, 4 px, arrondi), rupture (rouge, 6 px).

Résultat attendu de l'activité 1 : trois cartes

  • Vérifier : arrondi écrit seul (sans valeur) arrondit bien la carte.
  • Pourquoi epaisseur="4" a-t-il besoin de numberAttribute, alors que [epaisseur]="6" non ?
  1. Activité 2 : une infobulle

    • Créer appInfobulle: au survol (et au focus clavier), elle crée avec Renderer2 un <span class="infobulle"> contenant le texte reçu, et le retire ensuite.
    • La directive est exportée sous le nom infobulle: deux boutons « Montrer l'aide » et « Cacher » appellent ses méthodes afficher() et masquer() par une variable de référence.

Résultat attendu de l'activité 2 : l'aide affichée par un bouton

  • Vérifier : survoler 🖨️ affiche « Imprimer (Ctrl + P) » ; « Montrer l'aide » affiche la bulle du bouton ❔ sans le survoler.
  • Pourquoi utiliser Renderer2 plutôt que document.createElement()?
  1. Activité 3 : copier dans le presse-papiers

    • Créer appCopier: au clic, elle copie le texte reçu avec navigator.clipboard.writeText(), puis émet la sortie copie (le texte copié) ou, en cas d'échec, la sortie echec.
    • Afficher trois codes promo, chacun avec un bouton « Copier » ; un message « « … » copié ! » s'affiche deux secondes.

Résultat attendu de l'activité 3 : un code copié

  • Vérifier : coller (Ctrl + V) dans un éditeur donne bien « RENTREE-2026 ».
  • Pourquoi le message est-il géré par le composant et non par la directive ?
  1. Activité 4 : fermer au clic extérieur

    • Créer appClicExterieur: elle écoute les clics sur tout le document ('(document:click)') et émet sa sortie quand le clic a lieu hors de son hôte (contains()).
    • L'utiliser pour un menu déroulant « Mon compte » : un clic sur le bouton l'ouvre, un clic n'importe où ailleurs le ferme.

Résultat attendu de l'activité 4 : le menu ouvert

  • Vérifier : cliquer sur « Profil » ne ferme pas le menu ; cliquer sur le texte en dessous le ferme.
  • Que se passerait-il si la directive était placée sur le bouton au lieu du conteneur du menu ?
  1. Activité 5 : afficher selon le rôle

    • Créer le service Session (un signal role: 'invite', 'eleve' ou 'admin') et la directive structurelle *appSiRole: elle accepte un rôle ou une liste de rôles, et une clé sinon pour un modèle de remplacement.
    • Afficher « Télécharger le corrigé » aux élèves et aux administrateurs (sinon : « Inscrivez-vous… »), et « Modifier le cours » aux seuls administrateurs. Trois boutons changent le rôle.

Résultat attendu de l'activité 5 : la vue administrateur

  • Vérifier : en invité, seul le message d'inscription apparaît ; en élève, le lien de téléchargement ; en administrateur, aussi le bouton de modification.
  • Cette directive suffit-elle à protéger le corrigé ? Que faut-il faire côté serveur ?
  1. Activité 6 : répéter un modèle avec un contexte typé

    • Créer *appRepeter="n": elle affiche son modèle n fois, avec un contexte { $implicit, index, premier, dernier }, et une méthode statique ngTemplateContextGuard qui type ce contexte.
    • L'utiliser pour afficher une note en étoiles (pleines jusqu'à la note), une notation cliquable, et la liste « [S1, S2, S3, S4] » avec premier et dernier.

Résultat attendu de l'activité 6 : notes en étoiles

  • Vérifier : cliquer sur la 4e étoile affiche « 4 / 5 ».
  • Sans ngTemplateContextGuard, quel est le type de i dans let i = index? Quel risque cela fait-il courir ?
  1. Activité 7 : tester les directives

    • Écrire directives.spec.ts avec un composant hôte qui utilise appBordure, *appRepeter (piloté par un signal) et *appSiRole:
      • la bordure, l'épaisseur et l'arrondi sont appliqués ;
      • *appRepeter crée 3 vues, puis 1 quand le signal change, avec dernier correct ;
      • *appSiRole affiche « Refusé », puis « Zone admin » quand TestBed.inject(Session) passe en administrateur.
$ ng test --include src/app/act-directives/directives.spec.ts
 ✓ Activités : directives > Bordure applique la couleur, l'épaisseur et l'arrondi
 ✓ Activités : directives > Repeter crée n vues avec index et dernier, et suit le signal
 ✓ Activités : directives > SiRole affiche le modèle selon le rôle de la session

      Tests  3 passed (3)
  • Vérifier : 3 tests réussis.
  • Pourquoi le test de *appSiRole n'a-t-il pas besoin de cliquer sur un bouton ?
  • Les corrigés des sept activités, testés avec Angular 22, sont fournis dans l'archive de la leçon (dossier corriges/).
  1. Erreurs fréquentes

Message ou symptôme Cause Solution
NG8002: Can't bind to 'appSurligner' since it isn't a known property of 'p'. Directive non importée L'ajouter aux imports du composant
L'attribut appSurligner="…" ne fait rien, sans erreur Directive non importée (attribut HTML ordinaire) Importer la directive
NG0303: Can't bind to 'appSauf' since it isn't a known property of 'p' (console) Directive structurelle non importée Importer la directive ; pour *ngIf, importer NgIf ou passer à @if
NG5002: Can't have multiple template bindings on one element. Use only one attribute prefixed with * Deux directives structurelles sur un élément Placer l'une d'elles sur un <ng-container>
NG0201: No provider for TemplateRef found. Directive structurelle utilisée sans * Écrire *appSauf ou l'appliquer à un <ng-template>
NG8003: No directive found with exportAs 'compteurs'. Nom d'export erroné Utiliser la valeur de exportAs
NG8002: Can't bind to 'appSurligner' since it isn't a known property of 'app-bouton-danger'. Entrée d'une hostDirective non exposée ou renommée L'ajouter à inputs, sous le nom exposé
La directive ne s'applique à aucun élément Sélecteur sans crochets ('appSurligner' = une balise) selector: '[appSurligner]'
focus() sans effet Appelé dans le constructeur, avant l'affichage afterNextRender()
L'affichage ne suit pas une valeur modifiée Champ ordinaire dans un composant OnPush Utiliser un signal
epaisseur="4" reçu comme texte Attribut statique sans conversion input(2, { transform: numberAttribute })
  1. À retenir

    • Trois familles : les composants (avec template), les directives d'attribut (l'hôte change), les directives structurelles (des éléments apparaissent ou disparaissent).
    • Pour les classes et les styles, les liaisons [class] et [style] suffisent presque toujours ; *ngIf, *ngFor et [ngSwitch] sont remplacés par @if, @for, @switch.
    • Une directive d'attribut : un sélecteur entre crochets, des input() (dont un au nom du sélecteur), des output(), et des liaisons d'hôte dans host.
    • Pour agir sur le DOM : inject(ElementRef), Renderer2, et afterNextRender() pour attendre l'affichage.
    • Une directive structurelle utilise TemplateRef et ViewContainerRef; l'étoile * est un raccourci pour <ng-template>.
    • hostDirectives compose des comportements, exportAs nomme une directive, inject() retrouve une directive parente.
    • On teste une directive à travers un composant hôte de test.
Un composant dit « quoi afficher » ; une directive dit « comment se comporter ». Écrite une fois, elle s'applique à n'importe quel élément.