Skip to content

Commit 3937afc

Browse files
kirjsleonsenft
authored andcommitted
feat(forms): introduce SignalFormControl for Reactive Forms compatibility
This commit introduces `SignalFormControl`, a bridge implementation that allows Signal-based forms to interoperate with existing Reactive Forms infrastructure. It extends `AbstractControl` with standard methods and reactive observables while handling state propagation to parent containers.
1 parent f29fcd8 commit 3937afc

File tree

3 files changed

+123
-0
lines changed

3 files changed

+123
-0
lines changed

packages/forms/signals/compat/public_api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@
1414
export * from './src/api/compat_form';
1515
export * from './src/api/compat_validation_error';
1616
export * from './src/api/di';
17+
export {SignalFormControl} from './src/signal_form_control/signal_form_control';
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {inject, Injector, signal, WritableSignal} from '@angular/core';
10+
import {AbstractControl, FormControlStatus} from '@angular/forms';
11+
12+
import {compatForm} from '../api/compat_form';
13+
import {signalErrorsToValidationErrors} from '../../../src/api/rules';
14+
import {FormOptions} from '../../../src/api/structure';
15+
import {FieldState, FieldTree, SchemaFn} from '../../../src/api/types';
16+
import {normalizeFormArgs} from '../../../src/util/normalize_form_args';
17+
18+
/**
19+
* A `FormControl` that is backed by signal forms rules.
20+
*
21+
* This class provides a bridge between Signal Forms and Reactive Forms, allowing
22+
* signal-based controls to be used within a standard `FormGroup` or `FormArray`.
23+
*
24+
* @experimental
25+
*/
26+
export class SignalFormControl<T> extends AbstractControl {
27+
/** Source FieldTree. */
28+
public readonly fieldTree: FieldTree<T>;
29+
/** The raw signal driving the control value. */
30+
public readonly sourceValue: WritableSignal<T>;
31+
32+
private readonly fieldState: FieldState<T>;
33+
34+
constructor(value: T, schemaOrOptions?: SchemaFn<T> | FormOptions, options?: FormOptions) {
35+
super(null, null);
36+
37+
const [model, schema, opts] = normalizeFormArgs<T>([signal(value), schemaOrOptions, options]);
38+
this.sourceValue = model;
39+
const injector = opts?.injector ?? inject(Injector);
40+
41+
this.fieldTree = schema
42+
? compatForm(this.sourceValue, schema, {injector})
43+
: compatForm(this.sourceValue, {injector});
44+
this.fieldState = this.fieldTree();
45+
46+
Object.defineProperty(this, 'value', {
47+
get: () => this.sourceValue(),
48+
});
49+
Object.defineProperty(this, 'errors', {
50+
get: () => signalErrorsToValidationErrors(this.fieldState.errors()),
51+
});
52+
}
53+
54+
override setValue(value: any): void {
55+
this.sourceValue.set(value);
56+
}
57+
58+
override patchValue(value: any): void {
59+
this.sourceValue.set(value);
60+
}
61+
62+
override getRawValue(): T {
63+
return this.value;
64+
}
65+
66+
override reset(): void {
67+
this.fieldState.reset(this.sourceValue());
68+
}
69+
70+
override get status(): FormControlStatus {
71+
if (this.fieldState.valid()) {
72+
return 'VALID';
73+
}
74+
if (this.fieldState.invalid()) {
75+
return 'INVALID';
76+
}
77+
return 'PENDING';
78+
}
79+
80+
override get valid(): boolean {
81+
return this.fieldState.valid();
82+
}
83+
84+
override get invalid(): boolean {
85+
return this.fieldState.invalid();
86+
}
87+
88+
override updateValueAndValidity(_opts?: Object): void {}
89+
90+
/** @internal */
91+
_updateValue(): void {}
92+
93+
/** @internal */
94+
_forEachChild(_cb: (c: AbstractControl) => void): void {}
95+
96+
/** @internal */
97+
_anyControls(_condition: (c: AbstractControl) => boolean): boolean {
98+
return false;
99+
}
100+
101+
/** @internal */
102+
_allControlsDisabled(): boolean {
103+
return false;
104+
}
105+
106+
/** @internal */
107+
_syncPendingControls(): boolean {
108+
return false;
109+
}
110+
}

packages/forms/signals/src/api/rules/validation/validation_errors.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import {ValidationErrors} from '@angular/forms';
910
import type {FormField} from '../../../directive/form_field_directive';
1011
import type {FieldTree} from '../../types';
1112
import type {StandardSchemaValidationError} from './standard_schema';
@@ -487,3 +488,14 @@ export type NgValidationError =
487488
| PatternValidationError
488489
| EmailValidationError
489490
| StandardSchemaValidationError;
491+
492+
export function signalErrorsToValidationErrors(errors: ValidationError[]): ValidationErrors | null {
493+
if (errors.length === 0) {
494+
return null;
495+
}
496+
const errObj: ValidationErrors = {};
497+
for (const error of errors) {
498+
errObj[error.kind] = error;
499+
}
500+
return errObj;
501+
}

0 commit comments

Comments
 (0)