refactor(forms): relax [formField] input type from FieldTree to Field

`FieldTree` was an unnecessarily specific type for the `[formField]`
input. It forced the directive to care about what _kind_ of `FieldTree`
was bound–specifically whether it was Reactive Forms compatible or not.
This made it difficult to author forms system-agnostic components with
passthrough `[formField]` inputs.
This commit is contained in:
Leon Senft 2026-02-11 11:45:20 -08:00 committed by GitHub
parent 39cff9c235
commit 3606902b33
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 45 additions and 30 deletions

View file

@ -5,7 +5,6 @@
```ts
import { AbstractControl } from '@angular/forms';
import * as _angular_forms from '@angular/forms';
import { ControlValueAccessor } from '@angular/forms';
import { EventEmitter } from '@angular/core';
import { FormControlState } from '@angular/forms';

View file

@ -5,7 +5,6 @@
```ts
import { AbstractControl } from '@angular/forms';
import * as _angular_forms from '@angular/forms';
import { ControlValueAccessor } from '@angular/forms';
import { FormControlStatus } from '@angular/forms';
import { HttpResourceOptions } from '@angular/common/http';
@ -121,6 +120,9 @@ export class EmailValidationError extends BaseNgValidationError {
readonly kind = "email";
}
// @public
export type Field<TValue, TKey extends string | number = string | number> = () => FieldState<TValue, TKey>;
// @public
export type FieldContext<TValue, TPathKind extends PathKind = PathKind.Root> = TPathKind extends PathKind.Item ? ItemFieldContext<TValue> : TPathKind extends PathKind.Child ? ChildFieldContext<TValue> : RootFieldContext<TValue>;
@ -189,14 +191,14 @@ export class FormField<T> {
readonly element: HTMLElement;
readonly errors: Signal<ValidationError.WithFieldTree[]>;
// (undocumented)
readonly fieldTree: i0.InputSignal<FieldTree<T>>;
readonly field: i0.InputSignal<Field<T>>;
focus(options?: FocusOptions): void;
readonly injector: Injector;
protected get interopNgControl(): InteropNgControl;
registerAsBinding(bindingOptions?: FormFieldBindingOptions): void;
readonly state: Signal<[T] extends [_angular_forms.AbstractControl<any, any, any>] ? CompatFieldState<T, string | number> : FieldState<T, string | number>>;
readonly state: Signal<FieldState<T, string | number>>;
// (undocumented)
static ɵdir: i0.ɵɵDirectiveDeclaration<FormField<any>, "[formField]", ["formField"], { "fieldTree": { "alias": "formField"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<FormField<any>, "[formField]", ["formField"], { "field": { "alias": "formField"; "required": true; "isSignal": true; }; }, {}, never, never, true, never>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<FormField<any>, never>;
}

View file

@ -48,7 +48,7 @@ runInEachFileSystem(() => {
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(
`Type 'null' is not assignable to type 'FieldTree<any, string | number>'.`,
`Type 'null' is not assignable to type 'Field<any, string | number>'.`,
);
});
@ -70,7 +70,7 @@ runInEachFileSystem(() => {
const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(extractMessage(diags[0])).toBe(
`Type 'string' is not assignable to type 'FieldTree<any, string | number>'.`,
`Type 'string' is not assignable to type 'Field<any, string | number>'.`,
);
});

View file

@ -171,6 +171,20 @@ export type AsyncValidationResult<E extends ValidationError = ValidationError> =
| ValidationResult<E>
| 'pending';
/**
* A field accessor function that returns the state of the field.
*
* @template TValue The type of the value stored in the field.
* @template TKey The type of the property key which this field resides under in its parent.
*
* @category types
* @experimental 21.2.0
*/
export type Field<TValue, TKey extends string | number = string | number> = () => FieldState<
TValue,
TKey
>;
/**
* An object that represents a tree of fields in a form. This includes both primitive value fields
* (e.g. fields that contain a `string` or `number`), as well as "grouping fields" that contain

View file

@ -26,7 +26,7 @@ import {
} from '@angular/core';
import {type ControlValueAccessor, NG_VALUE_ACCESSOR, NgControl} from '@angular/forms';
import {type ValidationError} from '../api/rules';
import type {FieldTree} from '../api/types';
import type {Field} from '../api/types';
import {InteropNgControl} from '../controls/interop_ng_control';
import {RuntimeErrorCode} from '../errors';
import {SIGNAL_FORMS_CONFIG} from '../field/di';
@ -97,7 +97,7 @@ export const FORM_FIELD = new InjectionToken<FormField<unknown>>(
],
})
export class FormField<T> {
readonly fieldTree = input.required<FieldTree<T>>({alias: 'formField'});
readonly field = input.required<Field<T>>({alias: 'formField'});
/** @internal */
readonly renderer = inject(Renderer2);
@ -108,7 +108,7 @@ export class FormField<T> {
/**
* `FieldState` for the currently bound field.
*/
readonly state = computed(() => this.fieldTree()());
readonly state = computed(() => this.field()());
/**
* The node injector for the element this field binding.
@ -162,7 +162,7 @@ export class FormField<T> {
() =>
this.parseErrorsSource()?.().map((err) => ({
...err,
fieldTree: untracked(this.fieldTree),
fieldTree: untracked(this.state).fieldTree,
formField: this as FormField<unknown>,
})) ?? [],
);

View file

@ -12,14 +12,14 @@ import {FormControl} from '@angular/forms';
import {compatForm} from '../../compat';
import {FormField} from '../../public_api';
describe('compatForm with [field] directive', () => {
describe('compatForm with [formField] directive', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideZonelessChangeDetection()],
});
});
it('should bind compat form to input with [field] directive', () => {
it('should bind compat form to input with [formField] directive', () => {
@Component({
imports: [FormField],
template: `
@ -44,7 +44,7 @@ describe('compatForm with [field] directive', () => {
expect(ageInput.value).toBe('5');
});
it('should bind root-level FormControl to input with [field] directive', () => {
it('should bind root-level FormControl to input with [formField] directive', () => {
@Component({
imports: [FormField],
template: `<input [formField]="f" />`,

View file

@ -18,17 +18,17 @@ import {
import {TestBed} from '@angular/core/testing';
import {
disabled,
FieldTree,
form,
FormCheckboxControl,
FormField,
FormValueControl,
required,
type Field,
} from '@angular/forms/signals';
describe('createComponent', () => {
describe('FormValueControl', () => {
it(`synchronizes value from '[field]' binding`, () => {
it(`synchronizes value from '[formField]' binding`, () => {
@Component({template: ''})
class CustomInput implements FormValueControl<string> {
readonly value = model.required<string>();
@ -62,7 +62,7 @@ describe('createComponent', () => {
expect(control().value()).toBe('from component');
});
it(`synchronizes properties from '[field]' binding`, () => {
it(`synchronizes properties from '[formField]' binding`, () => {
@Component({template: ''})
class CustomInput implements FormValueControl<string> {
readonly value = model.required<string>();
@ -98,7 +98,7 @@ describe('createComponent', () => {
});
describe('FormCheckboxControl', () => {
it(`synchronizes value from '[field]' binding`, () => {
it(`synchronizes value from '[formField]' binding`, () => {
@Component({template: ''})
class CustomCheckbox implements FormCheckboxControl {
readonly checked = model.required<boolean>();
@ -132,7 +132,7 @@ describe('createComponent', () => {
expect(control().value()).toBe(true);
});
it(`synchronizes properties from '[field]' binding`, () => {
it(`synchronizes properties from '[formField]' binding`, () => {
@Component({template: ''})
class CustomCheckbox implements FormCheckboxControl {
readonly checked = model.required<boolean>();
@ -170,7 +170,7 @@ describe('createComponent', () => {
it(`should not treat component with '[formField]' input as a control`, () => {
@Component({template: ''})
class TestCmp {
readonly formField = input.required<FieldTree<string>>();
readonly formField = input.required<Field<string>>();
readonly value = model.required<string>();
}
@ -193,7 +193,7 @@ describe('createComponent', () => {
expect(control().formFieldBindings()).toHaveSize(0);
});
it(`should throw for invalid '[field]' binding host`, () => {
it(`should throw for invalid '[formField]' binding host`, () => {
@Component({template: ''})
class InvalidFieldHost {}

View file

@ -19,7 +19,7 @@ import {
import {TestBed} from '@angular/core/testing';
import {FormControl} from '@angular/forms';
import {compatForm} from '../../compat';
import {FormField, form, type FieldTree} from '../../public_api';
import {FormField, form, type Field, type FieldTree} from '../../public_api';
describe('FieldState focus behavior', () => {
it('should focus a native control', async () => {
@ -174,7 +174,7 @@ describe('FieldState focus behavior', () => {
template: ``,
})
class CustomControl {
formField = input.required<FieldTree<string>>();
formField = input.required<Field<string>>();
}
@Component({
@ -198,7 +198,7 @@ describe('FieldState focus behavior', () => {
template: `<input #input />`,
})
class CustomControl {
formField = input.required<FieldTree<string>>();
formField = input.required<Field<string>>();
input = viewChild.required<ElementRef<HTMLInputElement>>('input');
constructor() {

View file

@ -47,7 +47,7 @@ import {
requiredError,
validateAsync,
type DisabledReason,
type FieldTree,
type Field,
type FormCheckboxControl,
type FormValueControl,
type ValidationError,
@ -60,7 +60,7 @@ import {
imports: [FormField],
})
class TestStringControl {
readonly formField = input.required<FieldTree<string>>();
readonly formField = input.required<Field<string>>();
readonly fieldDirective = viewChild.required(FormField);
}
@ -3226,7 +3226,7 @@ describe('field directive', () => {
template: `{{ formField()().value() }}`,
})
class WrapperCmp {
readonly formField = input.required<FieldTree<string>>();
readonly formField = input.required<Field<string>>();
}
@Component({
@ -3300,7 +3300,7 @@ describe('field directive', () => {
template: ``,
})
class ComplexControl {
readonly formField = input.required<FieldTree<string>>();
readonly formField = input.required<Field<string>>();
}
@Component({
@ -3321,7 +3321,7 @@ describe('field directive', () => {
template: ``,
})
class ComplexControl {
readonly formField = input.required<FieldTree<string>>();
readonly formField = input.required<Field<string>>();
constructor() {
inject(FormField, {optional: true, self: true})?.registerAsBinding();
@ -4252,7 +4252,7 @@ describe('field directive', () => {
template: '',
})
class CustomSubform {
readonly formField = input.required<FieldTree<string>>();
readonly formField = input.required<Field<string>>();
}
@Component({