mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
39cff9c235
commit
3606902b33
9 changed files with 45 additions and 30 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>'.`,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
})) ?? [],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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" />`,
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue