5. Les outputs et input models

1

Dans le dernier chapitre, on a vu comment passer des informations d’un composant parent à un composant enfant en utilisant des inputs. Aujourd’hui, on va voir comment renvoyer des données de notre composant enfant à son parent à travers des outputs (qu’on peut aussi appeler des événements).

Dans ce chapitre on va:

  • implémenter une barre de recherche
  • montrer comment réagir à des évènements
  • expliquer ce que sont les outputs et comment les utiliser
  • voir quelques fonctionnalités avancées des outputs
  • expliquer la fonction model, qui est un input et un output à la fois

À la fin de ce chapitre, nous aurons une barre de recherche interactive comme celle-ci :

Pour rappel, tout au long de ce cours, on va créer une application de gestion de collections de timbres, pièces, figurines, cartes à jouer ou autre. Dans le dernier chapitre, on a vu comment créer un projet Angular. Donc, on va partir du principe que vous avez un projet prêt à être utilisé.

application de gestion de collections

Création de la barre de recherche

Étant donné que nous souhaitons créer une barre de recherche, commençons par créer un composant « search-bar » :

Bash
ng g c components/search-bar

Ensuite, dans le fichier « search-bar.html », on va ajouter le code suivant :

search-bar.component.html

search-bar.html
<div class="search-box">
    <img src="img/search.png">
    <input id="live-search" type="search" placeholder="Search..." autocomplete="off">
    <button>Search</button>
</div>

Maintenant, ajoutons un peu de CSS pour rendre le tout un peu plus beau.

search-bar.scss
.search-box {
  display: flex;
  align-items: center;
  background: #fff;
  border: 1px solid #ccc;
  border-radius: 10px;
  padding: 5px 10px;
  width: 300px;
}

.search-box img {
  width: 18px;
  height: 18px;
  margin-right: 8px;
  flex-shrink: 0;
}

.search-box input {
  border: none;
  outline: none;
  flex: 1;
  font-size: 16px;
}

Ensuite, nous allons modifier le contenu de notre fichier « app.html » afin d’y ajouter notre barre de recherche :

app.html
<header id="collection-header">
    <h1>My Collection</h1>
    <app-search-bar></app-search-bar>
</header>
<section class="collection-grid">
    <app-collection-item-card [item]="linx"></app-collection-item-card>
    <app-collection-item-card [item]="coin"></app-collection-item-card>
</section>

Et ajoutons aussi un peu de css à app.scss afin de rendre le tout un peu plus esthétique :

app.scss
#collection-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin: 0 1rem;
}

.collection-grid {
    display: flex;
    width: 100%;
    gap: 1rem;
    flex-wrap: wrap;
    justify-content: center;
}

Ce qui nous donne le résultat suivant:

Maintenant, afin de montrer comment fonctionnent les outputs, on va modifier le fichier « app.html », afin qu’on affiche dans un premier temps le nombre de fois que le bouton de recherche a été appuyé. Donc, on y ajoutera un texte « Clicked Count:  » et on va aussi y afficher une propriété « count » qui va stocker le nombre de fois que le bouton a été appuyé.

app.html
<header id="collection-header">
    <h1>My Collection</h1>
    <div>
        <app-search-bar></app-search-bar>
        Clicked Count: {{ count }}
    </div>
</header>
<section class="collection-grid">
    <app-collection-item-card [item]="linx"></app-collection-item-card>
    <app-collection-item-card [item]="coin"></app-collection-item-card>
</section>

Modifions maintenant notre fichier « app.ts » afin d’y créer la propriété « count » et initialisons-la à 0. N’oublions pas non plus de rajouter notre « SearchBar » aux imports : 

app.ts
import { Component } from '@angular/core';
import { CollectionItemCard } from "./components/collection-item-card/collection-item-card";
import { CollectionItem } from './models/collection-item';
import { SearchBar } from "./components/search-bar/search-bar";

@Component({
  selector: 'app-root',
  imports: [CollectionItemCard, SearchBar],
  templateUrl: './app.html',
  styleUrl: './app.scss'
})
export class App {

  count = 0;

  coin!: CollectionItem;
  linx!: CollectionItem;
  
  constructor() {
    this.coin = new CollectionItem();
    this.coin.name = 'Pièce de 1972';
    this.coin.description = 'Pièce de 50 centimes de francs.';
    this.coin.rarity = 'Commune';
    this.coin.image = 'img/coin1.png';
    this.coin.price = 170;

    this.linx = new CollectionItem();
  }

}

Voici à quoi ressemble le résultat pour l’instant :

Réagir à des évènements

Avant de voir comment créer notre premier output, voyons comment on peut être notifié d’un clic effectué sur notre bouton de recherche. Eh bien, pour cela, Angular nous expose des événements par défaut que nous pouvons utiliser. Ici, l’évènement qui nous intéresse est l’événement click de notre bouton.

Pour pouvoir être notifié de ces événements, on va devoir spécifier, en tant qu’attribut du composant en question, le nom de l’évènement auquel on souhaite réagir, entouré de parenthèses. Donc ici, on aura entre parenthèses l’évènement “click” et puis on va devoir spécifier ce qu’on va faire avec l’output de l’évènement en question. Pour cela, on va dire que l’événement est égal suivi d’une expression TypeScript, qui peut être une simple assignation d’une variable ou un appel à une fonction, par exemple.

Dans un premier temps, je propose de créer une fonction qui va faire un console.log de “clicked” afin qu’on puisse confirmer que l’événement click appelle bien notre fonction. Pour cela, on modifie le fichier « search-bar.ts » comme ceci :

search-bar.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-search-bar',
  imports: [],
  templateUrl: './search-bar.html',
  styleUrl: './search-bar.scss'
})
export class SearchBar {

  searchClick() {
    console.log("clicked");
  }

}

Là, nous devons encore modifier le fichier « search-bar.html » afin d’y associer cette fonction à l’événement « click » :

search-bar.html
<div class="search-box">
    <img src="img/search.png">
    <input id="live-search" type="search" placeholder="Search..." autocomplete="off" />
    <button (click)="searchClick()">Search</button>
</div>

Les outputs

Regardons maintenant ce que nous devons faire afin de créer notre propre évènement que le composant « App » pourra utiliser afin d’être notifié à chaque clic de notre bouton de recherche. Pour cela, on crée une propriété de type OutputEmitterRef. Un OutputEmitterRef permet d’émettre un événement. En plus de cela, on va devoir lui assigner le résultat the la fonction output. OutputEmitterRef et output doivent tous deux être importer d’“@angular/core“ et on doit renseigner le type de valeurs qu’ils vont retourner en utiliser les symboles « < » et « > ». Dans notre exemple on ne va pas renvoyer de données, on va donc utiliser le type void.

search-bar.ts
import { Component, output, OutputEmitterRef } from '@angular/core';

@Component({
  selector: 'app-search-bar',
  imports: [],
  templateUrl: './search-bar.html',
  styleUrl: './search-bar.scss'
})
export class SearchBar {

  searchButtonClicked: OutputEmitterRef<void> = output<void>();

  searchClick() {
    console.log("clicked");
  }

}

Maintenant que nous avons notre output, il va falloir émettre un événement à chaque clic. Pour faire ça, on va supprimer notre console.log de notre méthode searchClick. Au lieu de cela, on va faire un appel à la méthode emit de l’attribut searchButtonClicked qu’on vient de créer:

search-bar.ts
import { Component, output, OutputEmitterRef } from '@angular/core';

@Component({
  selector: 'app-search-bar',
  imports: [],
  templateUrl: './search-bar.html',
  styleUrl: './search-bar.scss'
})
export class SearchBar {

  searchButtonClicked: OutputEmitterRef<void> = output<void>();

  searchClick() {
    this.searchButtonClicked.emit();
  }

}

On peut retourner dans notre fichier « app.html » et on va pouvoir utiliser l’output qu’on vient de définir. Pour cela, on va faire la même chose que lorsque nous avons utilisé l’événement click du bouton : on va taper searchClicked entre parenthèses, et lors de cet évènement, on va faire appel à une fonction increaseCount qui va incrémenter notre variable count :

app.html
<header id="collection-header">
    <h1>My Collection</h1>
    <div>
        <app-search-bar (searchButtonClicked)="increaseCount()"></app-search-bar>
        Clicked Count: {{ count }}
    </div>
</header>
<section class="collection-grid">
    <app-collection-item-card [item]="linx"></app-collection-item-card>
    <app-collection-item-card [item]="coin"></app-collection-item-card>
</section>

Dans notre fichier « app.ts », on va créer la méthode increaseCount :

app.ts
import { Component } from '@angular/core';
import { CollectionItemCard } from "./components/collection-item-card/collection-item-card";
import { CollectionItem } from './models/collection-item';
import { SearchBar } from "./components/search-bar/search-bar";

@Component({
  selector: 'app-root',
  imports: [CollectionItemCard, SearchBar],
  templateUrl: './app.html',
  styleUrl: './app.scss'
})
export class App {

  count = 0;

  coin!: CollectionItem;
  linx!: CollectionItem;
  
  constructor() {
    this.coin = new CollectionItem();
    this.coin.name = 'Pièce de 1972';
    this.coin.description = 'Pièce de 50 centimes de francs.';
    this.coin.rarity = 'Commune';
    this.coin.image = 'img/coin1.png';
    this.coin.price = 170;

    this.linx = new CollectionItem();
  }

  increaseCount() {
    this.count++;
  }

}

Maintenant, si on exécute le programme et qu’on clique sur le bouton « search », on voit bien que le compteur est incrémenté à chaque fois qu’on clique sur le bouton. 

Pour aller plus loin, nous allons examiner comment renvoyer la valeur entrée dans la barre de recherche à notre composant « App ». Tout d’abord, on va devoir faire un import de FormsModule dans le fichier « search-bar.ts ». Grâce à cet import, notre balis HTML <input> possède maintenant un attribut appelé ngModel, qui permettant de lier une variable avec le contenu de l’input.

Créons encore un input dans notre fichier « search-bar.ts », que nous appellerons search et auquel on va assigner une valeur quelconque par défaut:

search-bar.ts
import { Component, input, output, OutputEmitterRef } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-search-bar',
  imports: [FormsModule],
  templateUrl: './search-bar.html',
  styleUrl: './search-bar.scss'
})
export class SearchBar {

  search = input("Initial");
  searchButtonClicked: OutputEmitterRef<void> = output<void>();

  searchClick() {
    this.searchButtonClicked.emit();
  }

}

Ensuite, on peut ouvrir le fichier « search-bar.html » et passer cette propriété au paramètre ngModel de l’input afin d’afficher une valeur spécifique à l’intérieur de celui-ci :

search-bar.html
<div class="search-box">
    <img src="img/search.png">
    <input 
        id="live-search" 
        type="search" 
        placeholder="Search..." 
        autocomplete="off"
        [ngModel]="search()" 
    />
    <button (click)="searchClick()">Search</button>
</div>

Si vous lancez votre projet et que vous l’ouvrez dans le navigateur, vous verrez que la valeur dans notre input est maintenant bien initialisée avec la valeur “Initial”.

En plus de cela, l’import du FormsModule met aussi un évènement à disposition de l’input, et c’est l’évènement ngModelChange, qui va nous permettre de récupérer la valeur entrée à l’écran, au fur et à mesure qu’elle change. Je propose donc qu’on utilise la valeur retournée par cet événement pour faire appel à une fonction que l’on va appeler updateSearch et que l’on va implémenter dans une seconde.

search-bar.html
<div class="search-box">
    <img src="img/search.png">
    <input 
        id="live-search" 
        type="search" 
        placeholder="Search..." 
        autocomplete="off"
        [ngModel]="search()"
        (ngModelChange)="updateSearch($event)"
    />
    <button (click)="searchClick()">Search</button>
</div>

La valeur émise par un évènement est stockée dans une variable appelée $event, et ici nous passons donc cette valeur à notre fonction. Implémentons maintenant la fonction « updateSearch » dans le fichier « search-bar.ts », qui devra à son tour émettre la valeur reçue à son parent.

search-bar.ts
import { Component, input, output, OutputEmitterRef } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-search-bar',
  imports: [FormsModule],
  templateUrl: './search-bar.html',
  styleUrl: './search-bar.scss'
})
export class SearchBar {

  search = input("Initial");
  searchChange = output<string>();
  searchButtonClicked: OutputEmitterRef<void> = output<void>();

  searchClick() {
    this.searchButtonClicked.emit();
  }

  updateSearch(searchText: string) {
    this.searchChange.emit(searchText);
  }

}

Pour cela, on crée un output que l’on appelle searchChange, et dans notre fonction updateSearch, on fait un this.searchChange.emit(value), afin d’émettre la valeur reçue par notre input. Il ne nous reste plus qu’à utiliser cet événement dans notre composant « App » afin d’utiliser la valeur que l’utilisateur entrera dans notre input. Donc, on ouvre notre fichier « app.ts » et on va commencer par créer une variable search qu’on va initialiser avec une chaîne de caractères vide.

app.ts
import { Component } from '@angular/core';
import { CollectionItemCard } from "./components/collection-item-card/collection-item-card";
import { CollectionItem } from './models/collection-item';
import { SearchBar } from "./components/search-bar/search-bar";

@Component({
  selector: 'app-root',
  imports: [CollectionItemCard, SearchBar],
  templateUrl: './app.html',
  styleUrl: './app.scss'
})
export class App {

  count = 0;
  search = '';

  coin!: CollectionItem;
  linx!: CollectionItem;
  
  constructor() {
    this.coin = new CollectionItem();
    this.coin.name = 'Pièce de 1972';
    this.coin.description = 'Pièce de 50 centimes de francs.';
    this.coin.rarity = 'Commune';
    this.coin.image = 'img/coin1.png';
    this.coin.price = 170;

    this.linx = new CollectionItem();
  }

  increaseCount() {
    this.count++;
  }

}

Puis, dans notre fichier « app.html », on va assigner notre variable search à l’input search. En plus de cela, on va assigner la valeur émise par l’événement searchChange à notre variable search. Pour finir, on va afficher cette variable search à l’écran:

app.html
<header id="collection-header">
    <h1>My Collection</h1>
    <div>
        <app-search-bar 
            (searchButtonClicked)="increaseCount()"
            [search]="search"
            (searchChange)="search = $event"
        >
        </app-search-bar>
        Search: {{ search }}<br />
        Clicked Count: {{ count }}
    </div>
</header>
<section class="collection-grid">
    <app-collection-item-card [item]="linx"></app-collection-item-card>
    <app-collection-item-card [item]="coin"></app-collection-item-card>
</section>

Maintenant, si on ouvre notre navigateur et qu’on tape quelque chose dans la barre de recherche, on voit que la propriété search de notre composant « App » est automatiquement mise à jour:

Fonctionnalisés avancées

Two-way binding

Une chose importante à savoir est que lorsque vous avez un output qui a le même nom qu’un input, suivi de « Change », et que vous souhaitez que l’output assigne simplement la valeur de celui-ci à la variable que vous avez utilisée en input (comme dans notre exemple ci-dessus), vous pouvez utiliser une notation raccourcie afin d’arriver au même résultat. La notation en question consiste à entourer le nom de votre input de parenthèses, elles-mêmes entourées de crochets, et d’assigner votre propriété à cette expression.

app.html
<header id="collection-header">
    <h1>My Collection</h1>
    <div>
        <app-search-bar 
            (searchButtonClicked)="increaseCount()"
            [(search)]="search"
        >
        </app-search-bar>
        Search: {{ search }}<br />
        Clicked Count: {{ count }}
    </div>
</header>
<section class="collection-grid">
    <app-collection-item-card [item]="linx"></app-collection-item-card>
    <app-collection-item-card [item]="coin"></app-collection-item-card>
</section>

On appelle cette manière de lier une variable, à la fois en tant qu’input et output, le « two-way binding », par contraste à un simple input, ou un simple output qui sont des « one-way binding », donc des liaisons qui vont dans un seul sens. Si on sauvegarde et qu’on retourne voir le résultat, on voit que rien n’a changé et que notre variable « search » est bien mise à jour à chaque fois qu’on tape quelque chose dans notre barre de recherche.

Les alias

Il y a une dernière chose que j’aimerais mentionner avant de passer à la fonction model: notre output peut prendre un alias en paramètre. Un alias peut être utile si vous souhaitez donner un nom à votre output qui est différent du nom de l’attribut qui lui est associé. Imaginons, par exemple, que nous voulions que notre évènement searchButtonClicked soit appelé submit, sans pour autant changer le nom de notre attribut. Il suffit pour cela de reprendre notre fichier « search-bar.ts » et de passer un dictionnaire en paramètre à la fonction output, et d’y définir notre alias :

search-bar.ts
import { Component, input, output, OutputEmitterRef } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-search-bar',
  imports: [FormsModule],
  templateUrl: './search-bar.html',
  styleUrl: './search-bar.scss'
})
export class SearchBar {

  search = input("Initial");
  searchChange = output<string>();
  searchButtonClicked: OutputEmitterRef<void> = output<void>({
    alias: 'submit'
  });

  searchClick() {
    this.searchButtonClicked.emit();
  }

  updateSearch(searchText: string) {
    this.searchChange.emit(searchText);
  }

}

Maintenant nous devons également mettre à jour notre fichier « app.html » afin que celui-ci utilise notre alias:

app.html
<header id="collection-header">
    <h1>My Collection</h1>
    <div>
        <app-search-bar 
            (submit)="increaseCount()"
            [(search)]="search"
        >
        </app-search-bar>
        Search: {{ search }}<br />
        Clicked Count: {{ count }}
    </div>
</header>
<section class="collection-grid">
    <app-collection-item-card [item]="linx"></app-collection-item-card>
    <app-collection-item-card [item]="coin"></app-collection-item-card>
</section>

Là aussi, si vous ouvrez votre navigateur et cliquez sur le bouton de recherche, vous verrez que votre compteur de clics est bien mis à jour.

La fonction model

Le code que nous venons de voir est tout à fait correcte, mais depuis Angular 17.2 une nouvelle fonction permet de simplifier notre code. La fonction en question, est la fonction model qui est, tout comme la fonction input, un signal. La fonction model, est à la fois un input et un output. L’input généré par model a le nom automatiquement le nom de l’attribut en tant que nom, et l’output a pour nom le nom de l’attribut, suivi de “Change“. On peut donc, remplacer notre combo “search“ et “searchChange“ par un seul model.

Pour utiliser cette fonction model, on va retourner dans notre fichier « search-bar.ts » et l’importer de « @angular/core ». Puis, on va remplacer la fonction input par la fonction model, et on va supprimer notre output searchChange.

search-bar.ts
import { Component, model, output, OutputEmitterRef } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-search-bar',
  imports: [FormsModule],
  templateUrl: './search-bar.html',
  styleUrl: './search-bar.scss'
})
export class SearchBar {

  search = model("Initial");
  searchButtonClicked: OutputEmitterRef<void> = output<void>({
    alias: 'submit'
  });

  searchClick() {
    this.searchButtonClicked.emit();
  }

  updateSearch(searchText: string) {
    this.search.set(searchText);
  }

}

Comme on le disait avant, ce model va non seulement créer un input tout à fait standard, mais également un output avec le nom searchChange. Le model va ensuite se charger d’émettre un événement à chaque fois que sa valeur est changée.

Notez que pour que notre code fonctionne, on a du faire un petit changement. Dans notre fonction updateSearch, on n’utilise plus la fonction emit, car model n’a pas de fonction emit. Au lieu de cela, on doit faire un set qui va assigner la valeur de la barre de recharge au model.

On peut maintenant sauvegarder et vérifier que tout fonctionne comme avant :

On peut encore simplifier notre code grâce à cette fonction model . Pour cela, on va retourner dans notre fichier « search-bar.html », et là, on va regrouper les attributs ngModel et ngModelChange en un seul ngModel entouré de parenthèses et de crochets. Ici on va passer notre model search sans parenthèses en tant que valeur:

search-bar.html
<div class="search-box">
    <img src="img/search.png">
    <input 
        id="live-search" 
        type="search" 
        placeholder="Search..." 
        autocomplete="off"
        [(ngModel)]="search"
    />
    <button (click)="searchClick()">Search</button>
</div>

Maintenant, grâce à ce two-way binding, ngModel se chargera de lire la valeur de notre model, et en cas de modifications du texte entré dans la barre de recherche, ngModel se chargera également d’assigner la nouvelle valeur au model. On peut donc effacer la fonction updateSearch de notre fichier « search-bar.ts » qui, au final, ressemblera à ça:

search-bar.ts
import { Component, model, output, OutputEmitterRef } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-search-bar',
  imports: [FormsModule],
  templateUrl: './search-bar.html',
  styleUrl: './search-bar.scss'
})
export class SearchBar {

  search = model("Initial");
  searchButtonClicked: OutputEmitterRef<void> = output<void>({
    alias: 'submit'
  });

  searchClick() {
    this.searchButtonClicked.emit();
  }

}

Voilà, on a fait le tour de ce long chapitre et dans le prochain chapitre on parlera en détail de signaux et de détection de changements.

Laisser un commentaire