ANGULAR

Angular : 20 exercices corrigés

Prérequis : bases de HTML, JavaScript et TypeScript. Travaillez dans une application Angular 20 autonome créée avec Angular CLI 20, avec Node.js compatible (20.19+, 22.12+ ou 24.x). Chaque solution fournit le fichier src/main.ts complet ; remplacez-le pour essayer un exercice, sans mélanger les exemples. Les imports communs sont volontairement conservés pour faciliter les essais. Le fichier index.html du projet doit contenir app-root. Les formulaires sont des démonstrations locales, sans envoi à un serveur.

Méthode : répondez à la question de cours, réalisez l’exercice, puis ouvrez le corrigé et vérifiez les cas proposés. Comptez 10 à 25 minutes par exercice et 45 à 60 minutes pour le mini-projet.

Préparer les essais

npx --package @angular/cli@20 ng new atelier-nkilitech --standalone --routing=false --ssr=false --skip-tests --defaults
cd atelier-nkilitech
# Remplacer src/main.ts par une correction, puis :
npx ng serve
20 exercices · 20 questions de cours · 20 corrigés

Composants et templates

1

Comprendre un composant

Question de cours. Quels rôles jouent la classe, le template et le sélecteur ?

À réaliser. Créer un composant autonome présentant NKILItech.

Voir le corrigé 1Masquer le corrigé 1

Réponse et raisonnement. La classe contient les données et comportements ; le template décrit la vue ; le sélecteur indique le nom HTML du composant. bootstrapApplication démarre ce composant racine.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Bienvenue chez {{ centre }}</h1>`
})
class AppComponent {
centre = "NKILItech";
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Le titre affiche Bienvenue chez NKILItech.

2

Interpolation et propriété

Question de cours. Quelle différence entre {{ expression }} et [disabled] ?

À réaliser. Afficher une formation et désactiver le bouton lorsque les places valent zéro.

Voir le corrigé 2Masquer le corrigé 2

Réponse et raisonnement. L’interpolation produit du texte. La liaison de propriété transmet une valeur typée à la propriété du bouton : false le réactive. Un attribut HTML disabled="false" resterait au contraire présent et donc désactivant.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>{{ formation }}</h1><button type="button" [disabled]="places === 0">Réserver</button>`
})
class AppComponent {
formation = "Angular"; places = 0;
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Le bouton est désactivé à zéro ; remplacer places par 2 le réactive.

3

Réagir à un clic

Question de cours. À quoi sert une liaison (click) ?

À réaliser. Incrémenter un compteur de demandes à chaque clic.

Voir le corrigé 3Masquer le corrigé 3

Réponse et raisonnement. La liaison appelle une méthode de la classe lors de l’événement. L’état initial appartient à la classe ; aucun accès manuel au DOM n’est nécessaire.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Demandes : {{ demandes }}</h1><button type="button" (click)="ajouter()">Ajouter une demande</button>`
})
class AppComponent {
demandes = 0;
ajouter() { this.demandes += 1; }
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Après trois clics, le compteur affiche 3.

4

Afficher une alternative

Question de cours. Quand choisir @if plutôt que masquer seulement avec CSS ?

À réaliser. Afficher Disponible pour trois places, puis Complet pour zéro.

Voir le corrigé 4Masquer le corrigé 4

Réponse et raisonnement. @if crée la branche correspondante dans la vue. La branche inactive n’est pas seulement rendue invisible. La condition doit utiliser une comparaison explicite de la capacité.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Atelier NKILItech</h1>@if (places > 0) { <p>Disponible</p> } @else { <p>Complet</p> }`
})
class AppComponent {
places = 3;
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Avec 3, Disponible apparaît ; avec 0, seul Complet apparaît.

5

Boucler sur un catalogue

Question de cours. Pourquoi une clé track stable est-elle utile ?

À réaliser. Afficher HTML et Kotlin avec leurs identifiants 1 et 2 ; traiter aussi un catalogue vide.

Voir le corrigé 5Masquer le corrigé 5

Réponse et raisonnement. track relie chaque objet à son élément de vue. Une clé métier unique reste stable si la liste est réordonnée. @empty fournit le message quand aucune formation n’existe.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Catalogue</h1><ul>@for (cours of catalogue; track cours.id) { <li>{{ cours.nom }}</li> } @empty { <li>Aucun atelier</li> }</ul>`
})
class AppComponent {
catalogue = [{id: 1, nom: "HTML"}, {id: 2, nom: "Kotlin"}];
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Deux lignes sont visibles ; avec catalogue = [], le message Aucun atelier les remplace.

État et interactions

6

Lire et modifier un signal

Question de cours. Pourquoi lit-on un signal avec des parenthèses ?

À réaliser. Créer un nombre de places réactif et réserver sans passer sous zéro.

Voir le corrigé 6Masquer le corrigé 6

Réponse et raisonnement. Le signal est un accesseur réactif : places() lit sa valeur. update calcule la nouvelle valeur depuis la précédente. La méthode protège aussi la règle métier, même si le bouton est désactivé.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Places : {{ places() }}</h1><button type="button" [disabled]="places() === 0" (click)="reserver()">Réserver</button>`
})
class AppComponent {
places = signal(2);
reserver() { this.places.update(n => Math.max(0, n - 1)); }
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Deux clics donnent zéro ; le bouton devient désactivé et le nombre ne devient pas négatif.

7

Calculer un état dérivé

Question de cours. Pourquoi computed évite-t-il une seconde variable à synchroniser ?

À réaliser. Calculer automatiquement le total pour une quantité de places à 15 000 FCFA.

Voir le corrigé 7Masquer le corrigé 7

Réponse et raisonnement. Le total dépend de quantite. computed recalcule la valeur dérivée quand la dépendance change ; aucun événement séparé ne doit remettre manuellement le total à jour.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Devis</h1><p>{{ quantite() }} places : {{ total() }} FCFA</p><button type="button" (click)="ajouter()">Une place de plus</button>`
})
class AppComponent {
quantite = signal(1);
total = computed(() => this.quantite() * 15000);
ajouter() { this.quantite.update(n => n + 1); }
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Au départ : 15000 FCFA ; après un clic : 30000 FCFA.

8

Saisir avec une référence de template

Question de cours. Quel objet désigne #recherche sur un input ?

À réaliser. Filtrer HTML, Kotlin et Angular selon une recherche insensible à la casse.

Voir le corrigé 8Masquer le corrigé 8

Réponse et raisonnement. La référence désigne ici l’élément input. Sa valeur est transmise au signal ; computed applique le filtre. trim rend une recherche composée d’espaces équivalente à une recherche vide.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Rechercher</h1><label for="q">Formation</label><input id="q" #recherche (input)="terme.set(recherche.value)"><ul>@for (nom of resultats(); track nom) { <li>{{ nom }}</li> } @empty { <li>Aucun résultat</li> }</ul>`
})
class AppComponent {
terme = signal("");
catalogue = ["HTML", "Kotlin", "Angular"];
resultats = computed(() => this.catalogue.filter(n => n.toLowerCase().includes(this.terme().trim().toLowerCase())));
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

KO affiche Kotlin ; xyz affiche Aucun résultat ; une saisie vide restaure les trois formations.

9

Appliquer une classe conditionnelle

Question de cours. Pourquoi préférer [class.complet] à une modification manuelle de classList ?

À réaliser. Mettre en évidence l’état complet et permettre de basculer la disponibilité.

Voir le corrigé 9Masquer le corrigé 9

Réponse et raisonnement. La classe visuelle dépend directement de l’état. Le rendu reste cohérent après chaque changement. Le libellé Complet est aussi présent : la couleur seule ne doit pas porter l’information.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Disponibilité</h1><p [class.complet]="complet()">{{ complet() ? "Complet" : "Disponible" }}</p><button type="button" (click)="basculer()">Changer l’état</button>`
})
class AppComponent {
complet = signal(false);
basculer() { this.complet.update(v => !v); }
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Le clic change le texte et ajoute ou retire la classe complet ; inspecter l’élément pour le vérifier.

10

Modifier une liste réactive

Question de cours. Pourquoi créer un nouveau tableau pour mettre à jour un signal ?

À réaliser. Ajouter une formation Rust à une liste initiale HTML, sans doublon.

Voir le corrigé 10Masquer le corrigé 10

Réponse et raisonnement. Le nouveau tableau possède une référence différente, ce qui rend le changement observable avec la comparaison par défaut. Le contrôle includes rend un second clic sans effet.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Catalogue réactif</h1><ul>@for (nom of cours(); track nom) { <li>{{ nom }}</li> }</ul><button type="button" (click)="ajouter()">Ajouter Rust</button>`
})
class AppComponent {
cours = signal(["HTML"]);
ajouter() { this.cours.update(liste => liste.includes("Rust") ? liste : [...liste, "Rust"]); }
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Le premier clic ajoute Rust ; dix clics ne créent toujours que deux lignes.

Formulaires et composants

11

Valider un champ réactif

Question de cours. Pourquoi le contrôle de formulaire porte-t-il sa propre validité ?

À réaliser. Créer un courriel obligatoire valide avant d’activer Confirmer.

Voir le corrigé 11Masquer le corrigé 11

Réponse et raisonnement. FormControl rassemble valeur et règles de validation. nonNullable évite que reset produise null. Validators.email contrôle une forme syntaxique, sans prouver l’existence de la boîte.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Demander le programme</h1><label for="email">Courriel</label><input id="email" type="email" [formControl]="email"><button type="button" [disabled]="email.invalid" (click)="confirmer()">Confirmer</button><p>{{ message }}</p>`
})
class AppComponent {
email = new FormControl("", {nonNullable: true, validators: [Validators.required, Validators.email]});
message = "";
confirmer() { if (this.email.valid) this.message = "Saisie valide : " + this.email.value; }
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Vide ou abc désactive le bouton ; eleve@example.org l’active. Aucune donnée n’est envoyée.

12

Regrouper un formulaire

Question de cours. Quelle différence entre un FormControl et un FormGroup ?

À réaliser. Recueillir un nom obligatoire et entre 1 et 5 places entières, puis afficher la demande.

Voir le corrigé 12Masquer le corrigé 12

Réponse et raisonnement. Le groupe réunit plusieurs contrôles et agrège leur validité. Les validateurs min/max limitent la plage ; pattern refuse une quantité décimale. Le gestionnaire vérifie également la validité.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Demande de places</h1><form [formGroup]="form" (ngSubmit)="envoyer()"><label for="nom">Nom</label><input id="nom" formControlName="nom"><label for="places">Places</label><input id="places" type="number" min="1" max="5" step="1" formControlName="places"><button type="submit" [disabled]="form.invalid">Vérifier</button></form><p>{{ resultat }}</p>`
})
class AppComponent {
form = new FormGroup({nom: new FormControl("", {nonNullable: true, validators: [Validators.required, Validators.pattern(/\S/)]}), places: new FormControl(1, {nonNullable: true, validators: [Validators.required, Validators.min(1), Validators.max(5), Validators.pattern(/^[1-5]$/)]})});
resultat = "";
envoyer() { if (this.form.valid) { const v = this.form.getRawValue(); this.resultat = `${v.nom.trim()} : ${v.places} places`; } }
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Nom vide, espaces seuls, 0, 6 et 1,5 sont refusés ; Amina avec 2 donne Amina : 2 places.

13

Partager une logique avec un service

Question de cours. Que signifie providedIn: "root" dans cet exemple ?

À réaliser. Injecter un service qui fournit deux formations sans recopier le catalogue dans le composant.

Voir le corrigé 13Masquer le corrigé 13

Réponse et raisonnement. Le service est fourni à la racine de l’application. inject demande l’instance au système d’injection ; le composant ne la construit pas avec new. Le service de cet exercice est défini avant le composant dans le fichier complet.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Injectable({providedIn: "root"})
class CatalogueService { readonly catalogue = ["HTML", "Angular"]; }
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Catalogue du service</h1><ul>@for (nom of service.catalogue; track nom) { <li>{{ nom }}</li> }</ul>`
})
class AppComponent {
service = inject(CatalogueService);
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

HTML et Angular apparaissent à partir des données du service.

14

Passer une donnée à un enfant

Question de cours. Dans quel sens circule une entrée @Input ?

À réaliser. Créer une carte enfant qui reçoit le nom Kotlin depuis son parent.

Voir le corrigé 14Masquer le corrigé 14

Réponse et raisonnement. Le parent fournit la donnée via une liaison de propriété ; l’enfant l’affiche. Le composant enfant autonome doit être importé dans les imports du parent pour que son sélecteur soit reconnu.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({selector: "app-carte", standalone: true, template: `<h2>{{ nom }}</h2>`})
class CarteComponent { @Input() nom = ""; }
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule, CarteComponent],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Formation choisie</h1><app-carte [nom]="selection"></app-carte>`
})
class AppComponent {
selection = "Kotlin";
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Le titre de la carte enfant affiche Kotlin ; changer selection change la carte.

15

Émettre un événement enfant

Question de cours. Pourquoi un enfant émet-il une intention au lieu de modifier directement le parent ?

À réaliser. Faire émettre Kotlin par une carte quand on clique Choisir ; afficher le choix dans le parent.

Voir le corrigé 15Masquer le corrigé 15

Réponse et raisonnement. EventEmitter expose un événement typé. Le parent reçoit $event et décide de l’effet sur son état ; l’enfant reste réutilisable et ne connaît pas la structure du parent.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({selector: "app-choix", standalone: true, template: `<button type="button" (click)="choisir.emit('Kotlin')">Choisir Kotlin</button>`})
class ChoixComponent { @Output() choisir = new EventEmitter<string>(); }
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule, ChoixComponent],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Choix d’atelier</h1><app-choix (choisir)="selection = $event"></app-choix><p>Choix : {{ selection }}</p>`
})
class AppComponent {
selection = "aucun";
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Le clic sur Choisir Kotlin remplace aucun par Kotlin.

Robustesse et mini-projet

16

Afficher les états d’un chargement

Question de cours. Pourquoi distinguer chargement, réussite et échec ?

À réaliser. Simuler une demande locale asynchrone, avec un bouton réussite et un bouton échec.

Voir le corrigé 16Masquer le corrigé 16

Réponse et raisonnement. Le même emplacement affiche les états exclusifs. Le rejet est intercepté afin d’éviter une promesse non traitée. Les boutons bloqués durant l’attente empêchent des demandes concurrentes dans cette démonstration.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Chargement simulé</h1><button type="button" [disabled]="etat() === 'chargement'" (click)="charger(false)">Charger</button><button type="button" [disabled]="etat() === 'chargement'" (click)="charger(true)">Simuler un échec</button><p role="status">{{ message() }}</p>`
})
class AppComponent {
etat = signal("repos");
message = signal("Aucune demande");
async charger(echec: boolean) {
  this.etat.set("chargement"); this.message.set("Chargement…");
  try {
    const texte = await new Promise<string>((resolve, reject) => setTimeout(() => echec ? reject(new Error("Indisponible")) : resolve("Catalogue disponible"), 300));
    this.message.set(texte); this.etat.set("succes");
  } catch { this.message.set("Échec : réessayez"); this.etat.set("erreur"); }
}
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Charger aboutit à Catalogue disponible ; l’autre bouton affiche Échec : réessayez après l’attente.

17

Afficher du texte non fiable

Question de cours. Pourquoi ne pas contourner la protection pour afficher une saisie ordinaire ?

À réaliser. Afficher littéralement une chaîne contenant une balise HTML.

Voir le corrigé 17Masquer le corrigé 17

Réponse et raisonnement. L’interpolation traite la valeur comme du texte : la balise ne devient pas un élément de la page. Il n’est pas nécessaire d’utiliser innerHTML ni de déclarer la chaîne fiable pour un simple message.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Message reçu</h1><p>{{ message }}</p>`
})
class AppComponent {
message = "<strong>Inscription urgente</strong>";
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Les chevrons et strong restent visibles ; aucun élément strong n’est créé par la chaîne.

18

Prévoir une liste vide après suppression

Question de cours. Pourquoi supprimer par identifiant plutôt que par position affichée ?

À réaliser. Supprimer une demande ciblée et montrer Aucun dossier lorsque tout est retiré.

Voir le corrigé 18Masquer le corrigé 18

Réponse et raisonnement. L’identifiant conserve son sens après tri ou filtrage. filter crée un nouveau tableau en excluant le dossier visé ; @empty gère l’absence de données sans accès à un élément inexistant.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Dossiers</h1><ul>@for (d of dossiers(); track d.id) { <li>{{ d.nom }} <button type="button" (click)="supprimer(d.id)">Retirer {{ d.nom }}</button></li> } @empty { <li>Aucun dossier</li> }</ul>`
})
class AppComponent {
dossiers = signal([{id: 1, nom: "Amina"}, {id: 2, nom: "Boris"}]);
supprimer(id: number) { this.dossiers.update(liste => liste.filter(d => d.id !== id)); }
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Retirer Amina conserve Boris ; retirer Boris affiche Aucun dossier.

19

Tester une fonction métier

Question de cours. Pourquoi isoler un calcul pur de l’interface ?

À réaliser. Calculer un tarif entier et tester trois quantités ainsi qu’un cas invalide, sans dépendre d’un clic.

Voir le corrigé 19Masquer le corrigé 19

Réponse et raisonnement. Une fonction pure est testable indépendamment du composant. Les exceptions signalent un contrat invalide ; les tests doivent réellement échouer quand une attente est fausse, plutôt que seulement écrire un message.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

function tarif(n: number): number {
  if (!Number.isInteger(n) || n < 0) throw new Error("Quantité invalide");
  return n * 15000;
}
function verifierTarif(): string {
  for (const [n, attendu] of [[0, 0], [1, 15000], [3, 45000]]) {
    if (tarif(n) !== attendu) throw new Error("Tarif incorrect");
  }
  let refuse = false;
  try { tarif(-1); } catch { refuse = true; }
  if (!refuse) throw new Error("La quantité négative doit être refusée");
  return "4 vérifications réussies";
}
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Vérification du tarif</h1><p>{{ bilan }}</p>`
})
class AppComponent {
bilan = verifierTarif();
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Le composant affiche 4 vérifications réussies. Modifier une attente erronée provoque une erreur explicite.

20

Mini-projet : demandes NKILItech

Question de cours. Comment articuler formulaire, état réactif et total dérivé ?

À réaliser. Gérer un maximum de cinq places : saisir un nom, ajouter une demande, afficher le total à 15 000 FCFA par place et pouvoir retirer une demande.

Voir le corrigé 20Masquer le corrigé 20

Réponse et raisonnement. Le formulaire valide le nom, le signal contient les dossiers, computed calcule places restantes et recette. Un compteur produit des identifiants stables. Le gestionnaire contrôle la capacité même si le bouton est désactivé. Tout reste en mémoire et disparaît au rechargement.

Programme complet

import { Component, Injectable, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormControl, FormGroup, Validators } from '@angular/forms';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, FormsModule, ReactiveFormsModule],
  styles: [`.complet { font-weight: bold; border-left: 4px solid #c0392b; padding-left: 1rem; }`],
  template: `<h1>Inscriptions NKILItech</h1><form (ngSubmit)="ajouter()"><label for="nom">Nom</label><input id="nom" [formControl]="nom"><button type="submit" [disabled]="nom.invalid || restantes() === 0">Ajouter</button></form><p role="status">{{ restantes() }} places libres : {{ total() }} FCFA</p><ul>@for (d of dossiers(); track d.id) { <li>{{ d.nom }} <button type="button" (click)="retirer(d.id)">Retirer {{ d.nom }}</button></li> } @empty { <li>Aucune inscription</li> }</ul>`
})
class AppComponent {
nom = new FormControl("", {nonNullable: true, validators: [Validators.required, Validators.pattern(/\S/)]});
dossiers = signal<{id: number; nom: string}[]>([]);
private prochainId = 1;
restantes = computed(() => 5 - this.dossiers().length);
total = computed(() => this.dossiers().length * 15000);
ajouter() {
  if (this.nom.invalid || this.restantes() === 0) return;
  const dossier = {id: this.prochainId++, nom: this.nom.value.trim()};
  this.dossiers.update(liste => [...liste, dossier]); this.nom.reset();
}
retirer(id: number) { this.dossiers.update(liste => liste.filter(d => d.id !== id)); }
}
bootstrapApplication(AppComponent).catch(error => console.error(error));

Résultat attendu et vérification.

Deux inscriptions donnent 3 places libres et 30000 FCFA ; à cinq le bouton est bloqué ; retirer un dossier libère une place.

Pour poursuivre

Télécharger les 60 programmes et les consignes (ZIP)

Ces exercices originaux utilisent le contexte pédagogique NKILItech. Les documents de référence permettent d’approfondir les notions :

Autres séries : Angular · HTML · Kotlin