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.
This commit is contained in:
kirjs 2026-01-16 11:07:02 -05:00 committed by Leon Senft
parent f29fcd882a
commit 3937afc316
3 changed files with 123 additions and 0 deletions

View file

@ -14,3 +14,4 @@
export * from './src/api/compat_form';
export * from './src/api/compat_validation_error';
export * from './src/api/di';
export {SignalFormControl} from './src/signal_form_control/signal_form_control';

View file

@ -0,0 +1,110 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.dev/license
*/
import {inject, Injector, signal, WritableSignal} from '@angular/core';
import {AbstractControl, FormControlStatus} from '@angular/forms';
import {compatForm} from '../api/compat_form';
import {signalErrorsToValidationErrors} from '../../../src/api/rules';
import {FormOptions} from '../../../src/api/structure';
import {FieldState, FieldTree, SchemaFn} from '../../../src/api/types';
import {normalizeFormArgs} from '../../../src/util/normalize_form_args';
/**
* A `FormControl` that is backed by signal forms rules.
*
* This class provides a bridge between Signal Forms and Reactive Forms, allowing
* signal-based controls to be used within a standard `FormGroup` or `FormArray`.
*
* @experimental
*/
export class SignalFormControl<T> extends AbstractControl {
/** Source FieldTree. */
public readonly fieldTree: FieldTree<T>;
/** The raw signal driving the control value. */
public readonly sourceValue: WritableSignal<T>;
private readonly fieldState: FieldState<T>;
constructor(value: T, schemaOrOptions?: SchemaFn<T> | FormOptions, options?: FormOptions) {
super(null, null);
const [model, schema, opts] = normalizeFormArgs<T>([signal(value), schemaOrOptions, options]);
this.sourceValue = model;
const injector = opts?.injector ?? inject(Injector);
this.fieldTree = schema
? compatForm(this.sourceValue, schema, {injector})
: compatForm(this.sourceValue, {injector});
this.fieldState = this.fieldTree();
Object.defineProperty(this, 'value', {
get: () => this.sourceValue(),
});
Object.defineProperty(this, 'errors', {
get: () => signalErrorsToValidationErrors(this.fieldState.errors()),
});
}
override setValue(value: any): void {
this.sourceValue.set(value);
}
override patchValue(value: any): void {
this.sourceValue.set(value);
}
override getRawValue(): T {
return this.value;
}
override reset(): void {
this.fieldState.reset(this.sourceValue());
}
override get status(): FormControlStatus {
if (this.fieldState.valid()) {
return 'VALID';
}
if (this.fieldState.invalid()) {
return 'INVALID';
}
return 'PENDING';
}
override get valid(): boolean {
return this.fieldState.valid();
}
override get invalid(): boolean {
return this.fieldState.invalid();
}
override updateValueAndValidity(_opts?: Object): void {}
/** @internal */
_updateValue(): void {}
/** @internal */
_forEachChild(_cb: (c: AbstractControl) => void): void {}
/** @internal */
_anyControls(_condition: (c: AbstractControl) => boolean): boolean {
return false;
}
/** @internal */
_allControlsDisabled(): boolean {
return false;
}
/** @internal */
_syncPendingControls(): boolean {
return false;
}
}

View file

@ -6,6 +6,7 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {ValidationErrors} from '@angular/forms';
import type {FormField} from '../../../directive/form_field_directive';
import type {FieldTree} from '../../types';
import type {StandardSchemaValidationError} from './standard_schema';
@ -487,3 +488,14 @@ export type NgValidationError =
| PatternValidationError
| EmailValidationError
| StandardSchemaValidationError;
export function signalErrorsToValidationErrors(errors: ValidationError[]): ValidationErrors | null {
if (errors.length === 0) {
return null;
}
const errObj: ValidationErrors = {};
for (const error of errors) {
errObj[error.kind] = error;
}
return errObj;
}