Frontend
Traductions
Angular
- Bien penser à Unsubscribe les souscriptions lors de la destruction d'un composant, pour cela utiliser
SubSinkAdapter
(étendre la classe et utiliserthis.subs.add(ma_souscription)
)
Les souscriptions suivantes sont fermées automatiquement :
- appel HttpClient
- async Pipe dans un template - Utilisation d'un
Resolver
lors de l'accès à une ressource unitaire afin d'éviter une navigation arrière ou une page vide si la ressource n'existe pas (ex: détail d'un évènement).
Formulaire
Utilisation des Reactive Forms (docs) plutôt que les template-driven forms.
Structure
Voici une idée de la forme globale d'un formulaire. Des explications plus précises sont donnés sur les parties suivantes.
<form [formGroup]="form">
<!-- legende lorsqu'il y a au moins un champ obligatoire -->
<shared-required-fields-legend />
<!-- bloc d'affichage des erreurs -->
<div class="mb-3" *ngIf="error">
<shared-simple-alert type="error" [message]="error"></shared-simple-alert>
</div>
<!-- Champs du formulaire -->
<div class="mb-3">
<!-- appFormLabel indique que le champ est obligatoire -->
<label
for="modal-cancel-ouverture-reason"
class="form-label"
appFormLabel
>...</label
>
<!-- is-invalid permet d'avoir les styles bootstrap lorsque le champ est en erreur-->
<!-- (accessibilité) aria-invalid permet de connaitre la validité du champ -->
<!-- (accessibilité) aria-describedby permet d'ajouter un lien vers le conteneur des messages d'erreur -->
<textarea
class="form-control"
id="modal-cancel-ouverture-reason"
formControlName="reason"
[ngClass]="{
'is-invalid':
form.controls.reason.touched && form.controls.reason.invalid
}"
[attr.aria-invalid]="
form.controls.reason.touched && form.controls.reason.invalid
"
aria-describedby="modal-cancel-ouverture-reason-errors"
required
></textarea>
<!-- conteneur des erreurs de ce champ -->
<shared-errors id="modal-cancel-ouverture-reason-errors">
<!-- si on test la présence obligatoire d'une valeur on peut utiliser ce composant -->
<shared-required-error
*ngIf="form.controls.reason.touched && form.controls.reason.errors?.['required']"
></shared-required-error>
<!-- ici on peut rajouter d'autres types d'erreurs -->
<shared-error *ngIf="monTest">Mon message d'erreur spécifique</shared-error>
</shared-errors>
</div>
</form>
Id des champs de formulaire des modal
Les ids utilisés pour ces champs doivent être préfixés par le nom du modal. Cela permet d'éviter d'avoir des doublons d'id (HTML non valide) si la page appelant le modal contient deja un formulaire avec cet id.
Ici on voit que l'id est préfixé, par contre le formControlName est name puisqu'il est dans le scope du formulaire du composant. L'attribut aria-describedby (présenté plus bas) utilise aussi le préfixe.
<input
type="text"
class="form-control"
id="modal-description-edition-name"
formControlName="name"
[ngClass]="{
'is-invalid':
form.controls.name.touched && form.controls.name.invalid
}"
[attr.aria-invalid]="
form.controls.name.touched && form.controls.name.invalid
"
aria-describedby="modal-description-edition-name-errors"
required
/>
Affichage champs obligatoires / optionnels
Nous avons choisi de marquer les champs obligatoires et optionnel d'un repère visuel :
- une asterisque
*
pour les champs obligatoires - un texte
- Optionnel
pour les champs optionnels
Pour marquer qu'un champ est obligatoire, il faut rajouter à son <label>
l'attribut de la directive appFormLabel
ou appFormLabel=required
.
Pour marquer qu'un champ est optionnel, il faut rajouter à son <label>
l'attribut de la directive appFormLabel=optional
.
Un champ peut être conditionnellement obligatoire, dans ce cas, il faut "binder" l'attribut, ex : [appFormLabel]="isEdition ? 'required' : 'optional'"
.
Une légende doit être indiqué en haut du formulaire lorsqu'il y a au moins un champ obligatoire.
Il faut utiliser le composant <shared-required-fields-legend />
.
Composants
- Radio group en ligne (shared)
<shared-inline-radio-group
[formGroup]="form"
[name]="'avenantAutorise'"
[label]="monLabel"
[required]="true"
></shared-inline-radio-group>
- Date picker (librairie ngbootstrap)
Exemple minimal de configuration d'un datepicker.
<div class="input-group">
<input
formControlName="date"
ngbDatepicker
#modalOuvertureDatepicker="ngbDatepicker"
[minDate]="minDate"
/>
<button
class="btn btn-outline-secondary"
(click)="modalOuvertureDatepicker.toggle()"
type="button"
>
<fa-icon [icon]="icons.calendar"></fa-icon>
</button>
</div>
Accessibilité (A11y) avec ARIA
Voici les attributs minimums sont à utiliser :
- required est suffisant pour la plupart des input, pour les radio-group il faut le placer sur l'element ayant le rôle radiogroup (il faut utiliser le composant
shared-inline-radio-group
qui gère ça). - aria-invalid permet d'indiquer que le champ est valide ou pas, il est fortement lié à la classe css d'invalidité qu'on utilise.
- aria-describedby permet de rajouter des indications sur le champ, notamment les messages d'erreur et d'aide sur un champ (voir section plus bas)
- (peu utilisé pour le moment) aria-labelledby permet de lier un champ à un label si aucun label n'est déjà relié avec l'attribut
for
. - (peu utilisé pour le moment)
tabindex=0
permet de rajouter un element non dynamique au defilement avec la toucheTAB
, il est utilisé dans les radio group et sur la légende des champs obligatoires afin que ça soit focusable et annoncé au Screen Reader.
Dans Angular pour binder dynamiquement une valeur d'un attribut html il faut le prefixer par [attr.]
, ex: [attr.aria-invalid]
, si la valeur est statique alors on met directement aria-invalid
.
Elément <input/>
et <textarea/>
<!-- un id, ngClass + aria-invalid pour binder la validité du champ, aria-describedby pour pointer sur le container d'erreur (si présent), attribut required si nécessaire -->
<input
type="text"
class="form-control"
id="modal-description-edition-name"
formControlName="name"
[ngClass]="{
'is-invalid':
form.controls.name.touched && form.controls.name.invalid
}"
[attr.aria-invalid]="
form.controls.name.touched && form.controls.name.invalid
"
aria-describedby="modal-description-edition-name-errors"
required
/>
Element <radio/>
- l'accessibilité n'est pas encore parfaite car plus complexe
<!-- sur le div ayant le role radiogroup : aria-required, aria-labelledby pointant sur le label, ng-class + aria-invalid, aria-describedby pointant sur le container d'erreur, tabindex=0 -->
<!-- sur le label à l'intérieur : un id -->
<!-- sur les inputs : un id + required ou non -->
<!-- sur les labels : l'attribut for pointant sur l'id de l'input -->
<div
[formGroup]="this.formGroup"
class="row align-items-center"
role="radiogroup"
[attr.aria-labelledby]="this.groupName"
[ngClass]="{
'is-invalid': this.control.touched && this.control.invalid
}"
aria-required="true"
[attr.aria-describedby]="this.errorContainerId"
[attr.aria-invalid]="this.control.touched && this.control.invalid"
tabindex="0"
>
<div
class="col-12 col-md-6 form-label"
[attr.id]="this.groupName"
[appFormLabel]="this.required ? 'required' : 'optional'"
>
{{ this.label }}
</div>
<div class="col">
<!-- input 1 -->
<div class="form-check form-check-inline">
<input
class="form-check-input"
type="radio"
[formControlName]="this.name"
[attr.id]="trueValueId"
[value]="true"
[required]="this.required"
/>
<label class="form-check-label" [attr.for]="trueValueId">
{{ 'COMMON.FORM.RADIO_BUTTON.OUI' | translate }}
</label>
</div>
<!--... input 2 -->
</div>
</div>
Element <select/>
<!-- un id, ngclass + aria-invalid, aria-describedby, required (si nécessaire) -->
<select
id="modal-assign-role-role"
formControlName="role"
class="form-select"
[ngClass]="{
'is-invalid':
form.controls.role.touched && form.controls.role.invalid
}"
[attr.aria-invalid]="
form.controls.role.touched && form.controls.role.invalid
"
aria-describedby="modal-assign-role-role-errors"
required
>
Element <ng-select/>
, accessibilité limitée par l'utilisation d'une librairie (état aria-invalid à améliorer)
<!-- labelForId (correspond à l'id qui sera donné à l'input), ngClass, [inputAttrs] permet de passer des attributs à l'input -> on y ajoute required et aria-describedby. -->
<ng-select
[items]="domainesList"
labelForId="domainesConvention"
bindLabel="libelle"
formControlName="domainesConvention"
class="form-control"
[ngClass]="{
'is-invalid':
form.controls.domainesConvention.touched &&
form.controls.domainesConvention.invalid
}"
[inputAttrs]="{
required: 'true',
'aria-describedby': 'domainesConvention-errors'
}"
>
</ng-select>
Lier les messages d'erreur
Lorsqu'un champ de formulaire contient des erreurs, il faut lier le container d'erreur au champ.
La convention pour l'id du container est : idchamp-errors.
<textarea id="monchamp" aria-describedby="monchamp-errors"></textarea>
<shared-errors id="monchamp-errors">
//... erreurs
</shared-errors>
Lier les messages d'aide
Lorsqu'un champ de formulaire est décrit par un champ d'aide il faut lier le champ de formulaire a son texte d'aide (ex: formulaire recherche utilisateur, on indique a l'utilisateur quoi renseigner dans le champ).
Ici on a également un champ d'erreur, l'attribut aria-describedby étant une liste d'id séparés par des espaces.
La convention pour l'id du message est : idchamp-help.
<input id="monchamp" aria-describedby="monchamp-help monchamp-errors"/>
<p id="monchamp-help" class="form-text">blabla</p>
<shared-errors id="monchamp-errors">
//... erreurs
</shared-errors>
Chargement et bouton submit
Pour éviter l'appel en doublon d'un service (double clic sur un bouton submit), il vaut mieux bloquer les boutons de formulaires avec un booléen de chargement.
Dans le composant
onSubmit() {
// reset des erreurs
this.error = '';
// on démarre le chargement
this.loading = true;
this.detailService
.updateDescription(this.form.getRawValue())
.pipe(finalize(() => (this.loading = false))) // on enleve le chargement
.subscribe({
next: () => {
// si succès
},
error: (error: ApiError) => {
// affichage du message d'erreur dans la page ou appel au service de notification selon le cas
this.error = error.message;
},
});
}
Dans le template
<button
[disabled]="!form.valid || isLoading"
type="button"
class="btn btn-primary"
(click)="onPrimaryAction()"
>