Ein Custom Control ist eine Komponente, die sich in einem Formular wie ein <input> verhält: Sie hat einen Wert, kann deaktiviert werden und weiß, ob sie ungültig oder berührt ist. Angular kennt zwei Verträge: FormValueControl für Signal Forms (einfach, auf Basis von model()) und den klassischen ControlValueAccessor für Reactive / Template-driven Forms.
| FormValueControl (v22) | ControlValueAccessor | |
|---|---|---|
| Verwendet mit | [formField] (Signal Forms) | formControl, formControlName, ngModel |
| Wert | value = model<T>() | writeValue + registerOnChange |
| Zustände | optionale Inputs: disabled, invalid, errors, touched, required… | nur setDisabledState |
| Provider | keiner | NG_VALUE_ACCESSOR + forwardRef |
| Codezeilen | ~5 | ~20 |
FormValueControl: die Signal-Forms-Variante
Das einzige Pflichtmitglied ist value als model(). Jeden anderen Zustand (disabled, invalid, errors, required, min/max ...) deklarierst du als input(), nur wenn du ihn brauchst — die Direktive [formField] füllt ihn automatisch aus dem Schema des Formulars.
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); // das einzige Pflichtmitglied
readonly disabled = input(false, { transform: booleanAttribute }); // optional: nur deklarieren,
readonly invalid = input(false, { transform: booleanAttribute }); // was du verwendest
readonly touched = input(false, { transform: booleanAttribute });
readonly touch = output<void>(); // v22: „der Nutzer hat es berührt“
}valid: false · touched: false · model: {
"rating": 0,
"comment": ""
}Boolesche Controls
// Für boolesche Controls gibt es FormCheckboxControl (die Property heißt `checked`, nicht `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: die klassische Variante
Wenn deine Anwendung noch Reactive Forms verwendet (oder du eine Komponentenbibliothek hast, die überall funktionieren muss), implementierst du ControlValueAccessor. Über vier Methoden kommunizieren das Formular und das Control miteinander.
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); } // Formular → Control
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); // Control → Formular
}
}
// <app-rating-cva [formControl]="rating" /> oder formControlName="rating" oder [(ngModel)]Interoperabilität
Kompatibilität (v22):
Control Signal Forms ([formField]) Reactive / Template Forms
──────────────────────────────────────────────────────────────────────────────────────
FormValueControl / Checkbox ✓ nativ ✓ (Template- & Reactive-Support für FVC)
ControlValueAccessor ✓ (CVA-Interop) ✓ nativ
<input>, <select>, <textarea> ✓ ✓
ngNoCva: ein Attribut, das einen automatisch angewendeten CVA auf einem Element deaktiviert (v22).Wann ja, wann nein
- Eine Eingabe-UI, die es nativ nicht gibt: Rating, Tag-Input, Farbwähler, Datumsbereich, Editor.
- Ein „aufgehübschtes“ natives Input, das sich direkt ans Formular binden muss (z. B. ein Währungsfeld mit Formatierung).
- Du willst, dass Validierung, der Zustand disabled und touched automatisch funktionieren.
- Du gestaltest nur ein
<input>→ setz[formField]auf das native Input innerhalb einer Layout-Komponente (Label + Fehler) und projiziere es. - Eine Gruppe von Feldern (eine Adresse) → besser ein Sub-Formular bzw. ein wiederverwendbares Schema (
apply), kein Control.
Häufige Stolperfallen
- CVA ohne
multi: trueoder ohneforwardRef→ zur Laufzeit schwer verständliche Fehler. onChangewird innerhalb vonwriteValueaufgerufen → eine Schleife oder ein Formular, das als dirty markiert wird, ohne dass der Nutzer etwas tut.- Touched nicht setzen: Fehler „nachdem du das Feld berührt hast“ erscheinen nie. Sende beim Blur
touch(FVC) bzw. rufeonTouched(CVA) auf. - Barrierefreiheit: Ein Custom Control muss fokussierbar sein und die richtigen ARIA-Rollen haben (siehe Angular Aria).