11. Angular Material

0

Lors du chapitre précédent de nos cours Angular, nous avons vu comment créer des formulaires réactifs. Aujourd’hui nous allons voir comment rendre ce formulaire plus beau en utilisant Angular Material.

  • Pour cela, on va voir ce qu’est « Angular Material »
  • On verra comment installer Angular dans un projet existant
  • Puis, on verra comment utiliser les composants Angular Material dans nos formulaires

Pour rappel, cette série de vidéos s’inscrit dans une longue lignée de vidéos dont le fil rouge est la création d’une application de visualisation et gestion de cartes à collectionner de type Pokémon, Magic, Yu-Gi-Oh! ou autres.

Résultat final projet Angular

Commençons par expliquer ce qu’est « Angular Material ». Angular Material est une librairie de composants graphiques basé sur les spécifications « Material Design » de Google. Angular Material vient avec un large éventail de composants, tel que des boutons, des inputs, des menus, et bien plus. En plus de cela, les composants sont responsifs et facilement personnalisables.

Passons à l’installation d’Angular Material dans un projet existant. Pour cela, on va ouvrir notre projet, et dans un terminal on va taper:

Bash
ng add @angular/material

Une fois la commande exécutée, vous verrez une série de questions. Tout d’abord, on vous demande quel thème vous voulez utiliser. Vous avez le choix entre « Azur/Blue », « Rose/Red », « Magenta/Violet », « Cyan/Orange » et « Custom ». Pour notre exemple, je vais choisir « Azur/Blue ». Ensuite, on vous demande si vous souhaitez appliquer les topographies d’Angular Material à votre projet. Pour ma part, je vais choisir « yes ». Pour finir, on vous demande si vous voulez inclure les animations et les activer ou non, et là aussi, je vais choisir de les inclure et de les utiliser. Et voilà, Angular Material est installé et prêt à être utilisé dans notre projet.

Maintenant regardons à quoi ressemble notre formulaire auquel on souhaite ajouter des composants Material. Pour cela, exécutons le projet et ouvrons-le dans notre navigateur, puis cliquons sur l’une de nos cartes afin d’ouvrir le formulaire de modification d’une carte:

Modify Form 2

Sur le formulaire, on peut voir différents inputs et deux boutons. Je propose de commencer par les boutons. La première chose à faire est de vérifier si il y a un composant Angular Material qu’on pourrait utiliser. Pour cela, on peut se rendre à la page https://material.angular.io et cliquer sur le menu « components » pour voir la liste des composants disponibles.

Angular Material Components

Sur cette page, on voit qu’on a bien des boutons à notre disposition. Donc ouvrons la page dédié aux boutons et regardons ça un peu plus en détail.

Angular Material - Button Page

Chaque page de composants est divisée en trois tabs:

  1. Overview: comme son nom l’indique, elle donne une vue d’ensemble du composant
  2. API: dans la première ligne, elle vous indique quel module met ce composant à disposition, et puis elle explique en détail chaque paramètre du composant en question.
  3. Example: contient plein d’exemples différents d’utilisation.

Pour notre exemple, j’aimerais que le bouton « cancel » soit un bouton « basic », et que le bouton « save », soit un bouton « flat ». En appuyant sur le bouton “<>” en haut à droite de l’exemple, nous pouvons regarder le code de l’exemple en question.

Angular Material - Button HTML

Si on regarde le fichier « HTML », nous voyons que nous pouvons convertir notre simple bouton en bouton Material « basic » en rajoutant l’attribut « mat-button » à notre bouton « cancel ». Pour le bouton « save », nous pouvons donc utiliser l’attribut « mat-flat-button ». Bien entendu, il ne faut pas oublier d’importer le bon module afin que ces attributs fonctionnent, et dans ce cas, on doit importer le module « MatButtonModule » de « @angular/material/button » dans notre composant.

Ouvrons maintenant notre fichier « monster.component.ts » et importons le module « MatButtonModule »:

monster.component.ts
...
import { MatButtonModule } from '@angular/material/button';

@Component({
    selector: 'app-monster',
    standalone: true,
    imports: [ReactiveFormsModule, PlayingCardComponent, MatButtonModule],
    templateUrl: './monster.component.html',
    styleUrl: './monster.component.css'
})
export class MonsterComponent implements OnInit, OnDestroy {
    ...
}

Maintenant, ajoutons les attributs « material » à nos boutons dans notre fichier « monster.component.html »:

monster.component.html
...
    <button mat-button (click)="navigateBack()">Back</button>
    <button mat-flat-button type="submit" [disabled]="formGroup.invalid">Save</button>
...

Maintenant, si vous ouvrez le formulaire dans le navigateur, vous pouvez constater que nos boutons ont bien adapté leur apparence et qu’ils fonctionnent toujours comme avant.

Angular Material - Buttons

Passons maintenant à nos inputs. Rendons-nous de nouveau sur la page web d’Angular Material, et là cette fois-ci, regardons ce qu’ils proposent au niveau des inputs.

Angular Material - Inputs Documentation

Je propose de partir sur un input tout simple. Comme vous le voyez, l’input d’Angular Material vient avec son propre « label » et son propre composant pour afficher les erreurs (« mat-label » et « mat-error »). Pour convertir notre input en un « Input Material », il suffit de rajouter l’attribut « matInput » à notre input.

Angular Material - Inputs Documentation 2

Bien entendu, ici aussi, nous devons importer un module, et cette fois-ci, c’est le module « MatInputModule ». Importons ce module dans le fichier « monster.component.ts »:

monster.component.ts
...
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';

@Component({
    selector: 'app-monster',
    standalone: true,
    imports: [ReactiveFormsModule, PlayingCardComponent, MatButtonModule, MatInputModule],
    templateUrl: './monster.component.html',
    styleUrl: './monster.component.css'
})
export class MonsterComponent implements OnInit, OnDestroy {
    ...
}

Ensuite adaptons le premier input dans notre fichier « monster.component.html » comme suit:

monster.component.html
...
        <mat-form-field>
            <mat-label for="name">Name</mat-label>
            <input matInput id="name" name="name" type="text" formControlName="name"/>
            @if (isFieldValid('name')) {
                <mat-error class="error">This field is required.</mat-error>
            }
        </mat-form-field>
...

Adaptons encore un peu le « css » de notre formulaire et faisons en sorte que le « mat-form-field » prenne toute la place disponible. Pour cela, on va modifier notre fichier « monster.component.css » comme suit:

monster.component.css
form {
  max-width: 500px;
  margin: 20px;
  padding: 20px;
  background-color: white;
  border-radius: 10px;
}

mat-form-field {
  width: 100%;
}

Maintenant, si on retourne dans notre navigateur, on voit que notre input a bien été modifié et qu’il fonctionne lui aussi comme avant.

Angular Material - One Input Adapted

Faisons maintenant la même chose pour les autres inputs de type « text » ou « number »:

monster.component.html
<div class="preview">
    <app-playing-card [monster]="monster"></app-playing-card>
</div>
<div class="main">
    <form [formGroup]="formGroup" (submit)="submit($event)">
        <mat-form-field>
            <mat-label for="name">Name</mat-label>
            <input matInput id="name" name="name" type="text" formControlName="name"/>
            @if (isFieldValid('name')) {
                <mat-error class="error">This field is required.</mat-error>
            }
        </mat-form-field>
        <div class="form-field">
            <label for="image">Image</label>
            <input id="image" name="image" type="file" (change)="onFileChange($event)">
            @if (isFieldValid('image')) {
                <div class="error">This field is required.</div>
            }
        </div>
        <div class="form-field">
            <label for="type">Type</label>
            <select id="type" name="type" formControlName="type">
                @for (type of monsterTypes; track type) {
                    <option [value]="type">{{type}}</option>
                }
            </select>
        </div>
        <mat-form-field>
            <mat-label for="hp">HP</mat-label>
            <input matInput id="hp" name="hp" type="number" formControlName="hp">
            @if (isFieldValid('hp')) {
                @if (formGroup.get('hp')?.hasError('required')) {
                    <mat-error class="error">This field is required.</mat-error>
                }
                @if (formGroup.get('hp')?.hasError('min')) {
                    <mat-error class="error">This field must be bigger than 0.</mat-error>
                }
                @if (formGroup.get('hp')?.hasError('max')) {
                    <mat-error class="error">This field must be smaller or equal to 200.</mat-error>
                }
            }
        </mat-form-field>
        <mat-form-field>
            <mat-label for="figureCaption">Figure caption</mat-label>
            <input matInput id="figureCaption" name="figureCaption" type="text" formControlName="figureCaption">
            @if (isFieldValid('figureCaption')) {
                <mat-error class="error">This field is required.</mat-error>
            }
        </mat-form-field>
        <mat-form-field>
            <mat-label for="attackName">Attack name</mat-label>
            <input matInput id="attackName" name="attackName" type="text" formControlName="attackName">
            @if (isFieldValid('attackName')) {
                <mat-error class="error">This field is required.</mat-error>
            }
        </mat-form-field>
        <mat-form-field>
            <mat-label for="attackStrength">Attack strength</mat-label>
            <input matInput id="attackStrength" name="attackStrength" type="number" formControlName="attackStrength">
            @if (isFieldValid('attackStrength')) {
                @if (formGroup.get('attackStrength')?.hasError('required')) {
                    <mat-error class="error">This field is required.</mat-error>
                }
                @if (formGroup.get('attackStrength')?.hasError('min')) {
                    <mat-error class="error">This field must be bigger than 0.</mat-error>
                }
                @if (formGroup.get('attackStrength')?.hasError('max')) {
                    <mat-error class="error">This field must be smaller or equal to 200.</mat-error>
                }
            }
        </mat-form-field>
        <mat-form-field>
            <mat-label for="attackDescription">Attack Description</mat-label>
            <input matInput id="attackDescription" name="attackDescription" type="text" formControlName="attackDescription">
            @if (isFieldValid('attackDescription')) {
                <mat-error class="error">This field is required.</mat-error>
            }
        </mat-form-field>
        <div class="button-container">
            <button mat-button (click)="navigateBack()">Back</button>
            <button mat-flat-button type="submit" [disabled]="formGroup.invalid">Save</button>
        </div>
    </form>
</div>

Passons maintenant à notre « select ». Là aussi, Angular Material a un composant tout prêt, le « mat-select ». Pour cela, on va devoir importer le « MatSelectModule » pour aujourd’hui:

monster.component.ts
...
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';

@Component({
    selector: 'app-monster',
    standalone: true,
    imports: [ReactiveFormsModule, PlayingCardComponent, MatButtonModule, MatInputModule, MatSelectModule],
    templateUrl: './monster.component.html',
    styleUrl: './monster.component.css'
})
export class MonsterComponent implements OnInit, OnDestroy {
    ...
}

Maintenant, on va remplacer les tags « label », « select » et « option », par « mat-label », « mat-select » et « mat-option »:

monster.component.html
...
<mat-form-field>
    <mat-label for="type">Type</mat-label>
    <mat-select id="type" name="type" formControlName="type">
        @for (type of monsterTypes; track type) {
            <mat-option [value]="type">{{type}}</mat-option>
        }
    </mat-select>
</mat-form-field>
...

Une fois le HTML adapté, vous verrez le rendu suivant:

Angular Material - Select

Passons maintenant à notre input de type « file ». Angular Material n’a pas de composant par défaut pour ce type d’input, mais nous avons un moyen assez simple de pallier ce manque. Nous allons commencer par rendre notre input « hidden », et puis nous allons ajouter un bouton de type « mat-raised-button » qui se chargera d’exécuter le clic sur notre input qui ne sera donc pas visible à l’écran:

monster.component.html
...
        <div class="form-field">
            <button mat-raised-button (click)="$event.preventDefault(); imageUploader.click();"> Upload Image: {{ imageUploader.files?.[0]?.name || '...' }} </button>
            <input hidden #imageUploader id="image" name="image" type="file" (change)="onFileChange($event)">
            @if (isFieldValid('image')) {
                <div class="error">This field is required.</div>
            }
        </div>
 ...

Changeons aussi un peu les marges de notre « .form-field » pour rendre le tout un peu plus proportionné:

monster.component.css
.form-field {
    display: flex;
    flex-direction: column;
    margin-bottom: 20px;
}

Et voilà le résultat:

Angular Material - File Input

Pour finir, regardons comment utiliser le composant « Dialog » d’Angular Material. Pour cela, je propose tout d’abord de créer un bouton « Delete », qui servira à effacer un monstre sélectionné. Avant d’effacer le monstre, on va afficher un dialogue de confirmation afin de s’assurer de rien effacer par erreur. Commençons par ajouter le bouton à notre fichier « monster.component.html »:

monster.component.html
...
        </mat-form-field>
        <div class="button-container left">
        @if(monsterId != -1) {
            <button mat-flat-button color="warn" type="button" (click)="deleteMonster()"> Delete </button>
        }
        </div>
        <div class="button-container right">
            <button mat-button type="button" (click)="navigateBack()">Back</button>
            <button mat-flat-button type="submit" [disabled]="formGroup.invalid">Save</button>
        </div>
    </form>
</div>

Et modifions notre css afin que le bouton « delete » se retrouve à gauche et les boutons « back » et « save » soient à droite:

monster.component.css
... 
.button-container.left, .button-container.right {
    display: inline-block;
    width: 50%;
}

.button-container.right {
    text-align: right;
}

Créons aussi la fonction « deleteMonster » dans le fichier « monster.component.ts », mais laissons-la vide pour le moment:

monster.component.ts
...
 	deleteMonster() {
 	}
...

Nous avons maintenant l’interface suivante:

Angular Material - Form with delete button

Si vous regardez la documentation de « MatDialog », vous verrez que « MatDialog » a besoin d’un composant en input qui sera ensuite affiché dans le dialogue. Ce composant doit avoir 3 choses:

  • un titre qui contient l’attribut « mat-dialog-title »;
  • le contenu principal du dialogue qui se retrouvera dans le tag « mat-dialog-content », et
  • les boutons d’actions qui doivent être dans un tag « mat-dialog-actions »

Commençons donc tout de suite par créer ce composant qu’on va appeler « DeleteMonsterConfirmationDialogComponent ». Pour cela, on retourne dans le terminal et on tape:

Bash
ng generate component components/delete-monster-confirmation-dialog

Ouvrons maintenant le fichier « delete-monster-confirmation-dialog.component.html » et écrivons-y le code suivant:

delete-monster-confirmation-dialog.component.html
<h2 mat-dialog-title>Delete Confirmation</h2>
<mat-dialog-content>
    <p>Are you sure that you want to delete the selected monster ?</p>
</mat-dialog-content>
<mat-dialog-actions>
    <button mat-button mat-dialog-close>No</button>
    <button mat-flat-button [mat-dialog-close]="true">Yes</button>
</mat-dialog-actions>

Notez que « mat-dialog-close » indique que le bouton doit fermer le dialogue et qu’on peut lui passer une valeur en input qui sera ensuite renvoyer au composant qui a fait appel au dialogue.

Pour que cette page fonctionne correctement, nous devons encore inclure quelques modules au fichier « delete-monster-confirmation-dialog.component.ts »:

delete-monster-confirmation-dialog.component.ts
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogTitle, MatDialogContent, MatDialogActions, MatDialogClose } from '@angular/material/dialog';

@Component({
    selector: 'app-delete-monster-confirmation-dialog',
    standalone: true,
    imports: [MatButtonModule, MatDialogTitle, MatDialogContent, MatDialogActions, MatDialogClose],
    templateUrl: './delete-monster-confirmation-dialog.component.html',
    styleUrl: './delete-monster-confirmation-dialog.component.css'
})
export class DeleteMonsterConfirmationDialogComponent {

}

Maintenant que notre composant est prêt, utilisons-le afin de créer notre dialogue. Pour cela, on retourne dans notre fichier « monster.component.ts » et là, on va faire plusieurs choses:

  • On va importer « MatDialog » de « @angular/material/dialog » et l’injecter dans une propriété qu’on va appeler « dialog ».
  • Par la suite, on va modifier notre fonction « deleteMonster » afin d’ouvrir un nouveau dialog avec le composant qu’on vient de créer, et au final, on va souscrire à « afterClosed » afin d’être notifié de la fermeture du dialogue. Cette notification nous renverra en tant que résultat la valeur « true » si l’utilisateur a confirmé qu’il souhaite bien effacer le monster.
  • Pour finir, si nécessaire, on efface le monstre et on revient à la page principale.
monster.component.ts
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { Subscription } from 'rxjs';
import { MonsterType } from '../../utils/monster.utils';
import { Monster } from '../../models/monster.model';
import { PlayingCardComponent } from '../../components/playing-card/playing-card.component';
import { MonsterService } from '../../services/monster/monster.service';
import { MatButtonModule } from '@angular/material/button';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { DeleteMonsterConfirmationDialogComponent } from '../../components/delete-monster-confirmation-dialog/delete-monster-confirmation-dialog.component';
import { MatDialog } from '@angular/material/dialog';


@Component({
    selector: 'app-monster',
    standalone: true,
    imports: [ReactiveFormsModule, PlayingCardComponent, MatButtonModule, MatInputModule, MatSelectModule],
    templateUrl: './monster.component.html',
    styleUrl: './monster.component.css'
})
export class MonsterComponent implements OnInit, OnDestroy {

    private route = inject(ActivatedRoute);
    private fb = inject(FormBuilder);
    private router = inject(Router);
    private monsterService = inject(MonsterService);
    private readonly dialog = inject(MatDialog);
    
    private routeSubscription: Subscription | null = null;
    private formValuesSubscription: Subscription | null = null;

    formGroup = this.fb.group({
        name: ['', [Validators.required]],
        image: ['', [Validators.required]],
        type: [MonsterType.ELECTRIC, [Validators.required]],
        hp: [0, [Validators.required, Validators.min(1), Validators.max(200)]],
        figureCaption: ['', [Validators.required]],
        attackName: ['', [Validators.required]],
        attackStrength: [0, [Validators.required, Validators.min(1), Validators.max(200)]],
        attackDescription: ['', [Validators.required]]
    });

    monsterTypes = Object.values(MonsterType);
    monster: Monster = Object.assign(new Monster(), this.formGroup.value);
    monsterId = -1;

    ngOnInit(): void {
        this.formValuesSubscription = this.formGroup.valueChanges.subscribe(data => {
            this.monster = Object.assign(new Monster(), data);
        });
        this.routeSubscription = this.route.params.subscribe(params => {
            if (params['id']) {
                this.monsterId = parseInt(params['id']);
                const monsterData = this.monsterService.get(this.monsterId);
                if (monsterData) {
                  this.formGroup.patchValue(monsterData);
                }
            }
        });
    }

    navigateBack() {
        this.router.navigate(['/home']);
    }

    submit(event: Event) {
        event.preventDefault();
        if (this.monsterId === -1) {
          this.monsterService.add(this.monster);
        } else {
          this.monster.id = this.monsterId;
          this.monsterService.update(this.monster);
        }
        this.navigateBack();
    }

    isFieldValid(fieldName: string) {
        const formControl = this.formGroup.get(fieldName);
        return formControl?.invalid && (formControl?.dirty || formControl?.touched);
    }

    onFileChange(event: any) {
        const reader = new FileReader();
        if (event.target.files && event.target.files.length) {
            const [file] = event.target.files;
            reader.readAsDataURL(file);
            reader.onload = () => {
                this.formGroup.patchValue({
                    image: reader.result as string
                });
            };
        }
    }

    ngOnDestroy(): void {
        this.formValuesSubscription?.unsubscribe();
        this.routeSubscription?.unsubscribe();
    }

    deleteMonster() {
        const dialogRef = this.dialog.open(DeleteMonsterConfirmationDialogComponent);
        dialogRef.afterClosed().subscribe(confirmation => {
            if (confirmation) {
                this.monsterService.delete(this.monsterId);
                this.navigateBack();
            }
        })
    }

}

Et voilà, nous pouvons maintenant tester tout ça, et comme vous le voyez, on a un beau petit dialogue qui s’affiche si on souhaite effacer notre monstre.

Angular Material - Dialog

Si dans ce dialogue on indique qu’on ne souhaite pas effacer le monstre, le dialogue se ferme. Si on indique bien vouloir effacer le monstre, on retourne à la page principale avec la liste de tous nos monstres, et le monstre en question a bien été effacé.

Vous pouvez aussi passer des paramètres à votre dialogue, et il y a encore beaucoup d’autres composants dans la librairie Angular Material, mais grâce à cet article, vous devriez maintenant être en mesure d’utiliser n’importe quel composant à l’aide de la documentation.

Resources

Vous retrouvez ci-dessous un lien vers le code source utilisé dans ce chapitre. Ce lien est uniquement disponible pour les abonnés Standard et Premium. 

Quiz

Répondez au quiz ci-dessous afin de vérifier que vous avez bien compris le contenu de cette leçon. Les quiz sont uniquement disponibles pour les abonnés Standard et Premium.

Laisser un commentaire