refactor(forms): add unsupported method errors and docs

- Add disable, enable methods that throw with helpful messages
- Add validator methods (set/add/remove/clear) that throw
- Add setErrors, markAsPending methods that throw
- Add setters for dirty/pristine/touched/untouched that throw
- Add JSDoc with @usageNotes examples
- Add comprehensive unit tests for SignalFormControl
- Add FormGroup/FormArray integration tests
- Add web tests for CVA directive lifecycle
- Update migration docs with SignalFormControl usage
This commit is contained in:
kirjs
2026-01-16 11:13:29 -05:00
committed by Leon Senft
parent a2950805df
commit bbbdf0a6ed
6 changed files with 1384 additions and 13 deletions
@@ -83,7 +83,8 @@ In the template, use standard reactive syntax by binding the underlying control:
### Integrating a `FormGroup` into a signal form
You can also wrap an entire `FormGroup`. This is common when a reusable sub-section of a form—such as an **Address Block**—is still managed by legacy Reactive Forms.
You can also wrap an entire `FormGroup`. This is common when a reusable sub-section of a form—such as an **Address Block
**—is still managed by legacy Reactive Forms.
```typescript
import {signal} from '@angular/core';
@@ -206,11 +207,148 @@ const formValue = computed(() => ({
## Bottom-up migration
This is coming soon.
### Integrating a Signal Form into a `FormGroup`
You can use `SignalFormControl` to expose a signal-based form as a standard `FormControl`. This is useful when you want
to migrate leaf nodes of a form to Signals while keeping the parent `FormGroup` structure.
```typescript
import {Component, inject, Injector, signal} from '@angular/core';
import {ReactiveFormsModule, FormGroup} from '@angular/forms';
import {SignalFormControl} from '@angular/forms/signals/compat';
import {required} from '@angular/forms/signals';
@Component({
// ...
imports: [ReactiveFormsModule],
})
export class UserProfile {
private injector = inject(Injector);
// 1. Create a SignalFormControl, use signal form rules.
// Note: SignalFormControl requires an Injector
emailControl = new SignalFormControl('', this.injector, (p) => {
required(p, {message: 'Email is required'});
});
// 2. Use it in a legacy FormGroup
form = new FormGroup({
email: this.emailControl,
});
}
```
The `SignalFormControl` synchronizes values and validation status bi-directionally:
- **Signal -> Control**: Changing `email.set(...)` updates `emailControl.value` and the parent `form.value`.
- **Control -> Signal**: Typing in the input (updating `emailControl`) updates the `email` signal.
- **Validation**: Schema validators (like `required`) propagate errors to `emailControl.errors`.
### Disabling/Enabling control.
Imperative APIs for changing the enabled/disabled state (like `enable()`, `disable()`) are intentionally not supported
in `SignalFormControl`. This is because the state of the control should be derived from the signal state and rules.
Attempting to call disable/enable would throw an error.
```typescript {avoid}
import {signal, effect} from '@angular/core';
export class UserProfile {
readonly emailControl = new SignalFormControl('', this.injector);
readonly isLoading = signal(false);
constructor() {
// This will throw an error
effect(() => {
if (this.isLoading()) {
this.emailControl.disable();
} else {
this.emailControl.enable();
}
});
}
}
```
Instead, use disabled rule:
```typescript {prefer}
import {signal} from '@angular/core';
import {SignalFormControl} from '@angular/forms/signals/compat';
import {disabled} from '@angular/forms/signals';
export class UserProfile {
readonly isLoading = signal(false);
readonly emailControl = new SignalFormControl('', this.injector, (p) => {
// The control becomes disabled whenever isLoading is true
disabled(p, () => this.isLoading());
});
async saveData() {
this.isLoading.set(true);
// ... perform save ...
this.isLoading.set(false);
}
}
```
### Dynamic manipulation
Imperative APIs for adding or removing validators (like `addValidators()`, `removeValidators()`, `setValidators()`) are intentionally not supported in `SignalFormControl`.
Attempting to call these methods will throw an error.
```typescript {avoid}
export class UserProfile {
readonly emailControl = new SignalFormControl('', this.injector);
readonly isRequired = signal(false);
toggleRequired() {
this.isRequired.update((v) => !v);
// This will throw an error
if (this.isRequired()) {
this.emailControl.addValidators(Validators.required);
} else {
this.emailControl.removeValidators(Validators.required);
}
}
}
```
Instead, use `applyWhen` rule to conditionally apply validators:
```typescript {prefer}
import {signal} from '@angular/core';
import {SignalFormControl} from '@angular/forms/signals/compat';
import {applyWhen, required} from '@angular/forms/signals';
export class UserProfile {
readonly isRequired = signal(false);
readonly emailControl = new SignalFormControl('', this.injector, (p) => {
// The control becomes required whenever isRequired is true
applyWhen(
p,
() => this.isRequired(),
(p) => {
required(p);
},
);
});
}
```
### Manual Error Selection
The `setErrors()` and `markAsPending()` methods are not supported. In Signal Forms, errors are derived from validation rules and async validation status. If you need to report an error, it should be done declaratively via a validation rule in the schema.
## Automatic status classes
Reactive/Template Forms automatically adds [class attributes](/guide/forms/template-driven-forms#track-control-states) (such as `.ng-valid` or `.ng-dirty`) to facilitate styling control states. Signal Forms does not do that.
Reactive/Template Forms automatically adds [class attributes](/guide/forms/template-driven-forms#track-control-states) (
such as `.ng-valid` or `.ng-dirty`) to facilitate styling control states. Signal Forms does not do that.
If you want to preserve this behavior, you can provide the `NG_STATUS_CLASSES` preset: