Un contrôle personnalisé est un composant qui se comporte comme un <input> dans un formulaire : il a une valeur, peut être désactivé, et sait quand il est invalide ou touché. Angular propose deux contrats : FormValueControl pour les Signal Forms (simple, basé sur model()) et le ControlValueAccessor classique pour les Reactive / Template-driven forms.
| FormValueControl (v22) | ControlValueAccessor | |
|---|---|---|
| Utilisé avec | [formField] (Signal Forms) | formControl, formControlName, ngModel |
| Valeur | value = model<T>() | writeValue + registerOnChange |
| États | inputs facultatifs : disabled, invalid, errors, touched, required… | uniquement setDisabledState |
| Provider | aucun | NG_VALUE_ACCESSOR + forwardRef |
| Lignes de code | ~5 | ~20 |
FormValueControl : la version Signal Forms
Le seul membre obligatoire est value, sous forme de model(). Tout autre état (disabled, invalid, errors, required, min/max...) se déclare comme un input() seulement si vous en avez besoin : la directive [formField] le renseigne automatiquement à partir du schéma du formulaire.
import { Component, booleanAttribute, input, model, output } from '@angular/core';
import { FormValueControl } from '@angular/forms/signals';
@Component({
selector: 'app-rating-control',
host: { '[class.invalid]': 'invalid() && touched()', '(focusout)': 'touch.emit()' },
template: `
@for (star of [1, 2, 3, 4, 5]; track star) {
<button type="button" [class.on]="star <= value()" [disabled]="disabled()" (click)="value.set(star)">★</button>
}
`,
})
export class RatingControl implements FormValueControl<number> {
readonly value = model(0); // le seul membre obligatoire
readonly disabled = input(false, { transform: booleanAttribute }); // facultatif : ne déclarez que
readonly invalid = input(false, { transform: booleanAttribute }); // ce que vous utilisez
readonly touched = input(false, { transform: booleanAttribute });
readonly touch = output<void>(); // v22 : « l’utilisateur l’a touché »
}valid: false · touched: false · model: {
"rating": 0,
"comment": ""
}Contrôles booléens
// Pour les contrôles booléens, il y a FormCheckboxControl (la propriété s’appelle `checked`, pas `value`)
import { FormCheckboxControl } from '@angular/forms/signals';
export class Switch implements FormCheckboxControl {
readonly checked = model(false);
readonly disabled = input(false);
}
// <app-switch [formField]="settings.newsletter" />ControlValueAccessor : la version classique
Si votre application utilise encore les Reactive Forms (ou si vous avez une bibliothèque de composants qui doit fonctionner partout), vous implémentez ControlValueAccessor. Quatre méthodes permettent au formulaire et au contrôle de dialoguer.
import { forwardRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-rating-cva',
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => RatingCva), multi: true }],
host: { '(focusout)': 'onTouched()' },
template: `
@for (star of [1, 2, 3, 4, 5]; track star) {
<button type="button" [class.on]="star <= value()" [disabled]="disabled()" (click)="select(star)">★</button>
}
`,
})
export class RatingCva implements ControlValueAccessor {
protected readonly value = signal(0);
protected readonly disabled = signal(false);
private onChange: (v: number) => void = () => {};
protected onTouched: () => void = () => {};
writeValue(v: number | null) { this.value.set(v ?? 0); } // formulaire → contrôle
registerOnChange(fn: (v: number) => void) { this.onChange = fn; }
registerOnTouched(fn: () => void) { this.onTouched = fn; }
setDisabledState(d: boolean) { this.disabled.set(d); }
protected select(star: number) {
this.value.set(star);
this.onChange(star); // contrôle → formulaire
}
}
// <app-rating-cva [formControl]="rating" /> ou formControlName="rating" ou [(ngModel)]Interopérabilité
Compatibilité (v22) :
Control Signal Forms ([formField]) Reactive / Template Forms
──────────────────────────────────────────────────────────────────────────────────────
FormValueControl / Checkbox ✓ natif ✓ (support Template & Reactive pour FVC)
ControlValueAccessor ✓ (interop CVA) ✓ natif
<input>, <select>, <textarea> ✓ ✓
ngNoCva: un attribut qui désactive un CVA appliqué automatiquement sur un élément (v22).Quand OUI, quand NON
- Une interface de saisie qui n’existe pas nativement : notation, saisie de tags, sélecteur de couleur, plage de dates, éditeur.
- Un input natif « habillé » qui doit se lier directement au formulaire (p. ex. un champ de devise avec formatage).
- Vous voulez que la validation, l’état désactivé et touched fonctionnent automatiquement.
- Vous ne faites que styliser un
<input>→ placez[formField]sur l’input natif à l’intérieur d’un composant de mise en page (label + erreur) et projetez-le. - Un groupe de champs (une adresse) → mieux vaut un sous-formulaire / un schéma réutilisable (
apply), pas un contrôle.
Pièges fréquents
- CVA sans
multi: trueou sansforwardRef→ des erreurs difficiles à comprendre à l’exécution. onChangeappelé danswriteValue→ une boucle, ou un formulaire marqué dirty sans que l’utilisateur n’ait rien fait.- Ne pas marquer touched : les erreurs « après avoir touché le champ » n’apparaissent jamais. Émettez
touch(FVC) ou appelezonTouched(CVA) au blur. - Accessibilité : un contrôle personnalisé doit pouvoir recevoir le focus et avoir les bons rôles ARIA (voir Angular Aria).