AngularA Signal Forms sign-up form: schema validators, a cross-field rule and FormRoot submission
import { Component, signal } from "@angular/core";
import { form, FormField, FormRoot, required, email, minLength, validate } from "@angular/forms/signals";
interface Signup {
name: string;
email: string;
password: string;
confirm: string;
}
@Component({
selector: "app-root",
imports: [FormField, FormRoot],
template: `
<!-- formRoot adds novalidate and calls submit() with the options given to form() -->
<form [formRoot]="f">
<input id="name" placeholder="name" [formField]="f.name" />
<input id="email" placeholder="email" [formField]="f.email" />
<input id="password" type="password" placeholder="password" [formField]="f.password" />
<input id="confirm" type="password" placeholder="confirm" [formField]="f.confirm" />
<button id="save" type="submit">Save</button>
</form>
<p id="state">valid={{ f().valid() }} dirty={{ f().dirty() }} touched={{ f().touched() }}</p>
<p id="errors">{{ errorKinds() }}</p>
<p id="model">model={{ model().name }}|{{ model().email }}</p>
<p id="log">{{ log() }}</p>
`,
})
export class AppComponent {
model = signal<Signup>({ name: "", email: "", password: "", confirm: "" });
log = signal("");
f = form(
this.model,
(p) => {
required(p.name, { message: "Name is required" });
email(p.email);
minLength(p.password, 8);
validate(p.confirm, ({ value, valueOf }) =>
value() === valueOf(p.password) ? undefined : { kind: "mismatch", message: "Passwords differ" }
);
},
{
submission: {
action: async (root) => {
this.log.set("saved " + JSON.stringify(root().value()));
return undefined;
},
onInvalid: () => this.log.set("blocked: invalid"),
},
}
);
errorKinds() {
const fields = [this.f.name, this.f.email, this.f.password, this.f.confirm];
return fields.flatMap((field) => field().errors().map((e) => e.kind)).join(",") || "none";
}
}
AngularA FormValueControl built with model() next to a legacy ControlValueAccessor, both bound with [formField]
import { Component, forwardRef, input, model, output, signal } from "@angular/core";
import { ControlValueAccessor, FormControl, NG_VALUE_ACCESSOR, ReactiveFormsModule, Validators } from "@angular/forms";
import { form, FormField, FormValueControl, ValidationError, max, min } from "@angular/forms/signals";
@Component({
selector: "qty-stepper",
template: `
<button type="button" class="minus" [disabled]="disabled()" (click)="step(-1)">-</button>
<span class="qty">{{ value() }}</span>
<button type="button" class="plus" [disabled]="disabled()" (click)="step(1)">+</button>
@if (errors().length) { <em class="err">{{ errors()[0].kind }}</em> }
`,
})
export class QtyStepper implements FormValueControl<number> {
value = model(0);
disabled = input(false);
errors = input<readonly ValidationError.WithOptionalFieldTree[]>([]);
touch = output<void>();
step(delta: number) {
this.value.update((v) => v + delta);
this.touch.emit();
}
}
@Component({
selector: "legacy-toggle",
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => LegacyToggle), multi: true }],
template: `<button type="button" class="toggle" (click)="flip()">{{ on() ? "ON" : "OFF" }}</button>`,
})
export class LegacyToggle implements ControlValueAccessor {
on = signal(false);
private onChange: (v: boolean) => void = () => {};
writeValue(v: boolean) { console.log("CVA writeValue(" + v + ")"); this.on.set(!!v); }
registerOnChange(fn: (v: boolean) => void) { console.log("CVA registerOnChange"); this.onChange = fn; }
registerOnTouched() { console.log("CVA registerOnTouched"); }
setDisabledState(d: boolean) { console.log("CVA setDisabledState(" + d + ")"); }
flip() { this.on.update((x) => !x); this.onChange(this.on()); }
}
@Component({
selector: "app-root",
imports: [FormField, ReactiveFormsModule, QtyStepper, LegacyToggle],
template: `
<!-- Signal Forms binding both kinds of control -->
<qty-stepper id="sf-qty" [formField]="f.qty" />
<legacy-toggle [formField]="f.gift" />
<p class="model">{{ model().qty }} / {{ model().gift }} / valid={{ f().valid() }}</p>
<!-- The same new-style control driven by a reactive FormControl, no ControlValueAccessor -->
<qty-stepper id="rf-qty" [formControl]="legacyQty" />
`,
})
export class AppComponent {
model = signal({ qty: 1, gift: true });
f = form(this.model, (p) => {
min(p.qty, 1);
max(p.qty, 3);
});
legacyQty = new FormControl(5, { nonNullable: true, validators: Validators.max(5) });
}