mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(forms): Ability to manually register a form field binding in signal forms
This PR adds the ability to manually register a binding with the `FormField` directive. This is useful for a lower-level implementation that takes the field tree as an `input()` rather than relying on the automatic binding from `FormUiControl`.
This commit is contained in:
@@ -253,6 +253,7 @@ export {LContext as ɵLContext} from './render3/interfaces/context';
|
||||
export {
|
||||
ɵCONTROL,
|
||||
ɵFieldState,
|
||||
ɵFormFieldBindingOptions,
|
||||
ɵFormFieldDirective,
|
||||
ɵInteropControl,
|
||||
} from './render3/interfaces/control';
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
ɵCONTROL,
|
||||
ɵFieldState,
|
||||
ɵFormFieldDirective,
|
||||
type ɵCustomControl,
|
||||
type ɵFormFieldBindingOptions,
|
||||
} from '../interfaces/control';
|
||||
import {DirectiveDef} from '../interfaces/definition';
|
||||
import {TElementNode, TNode, TNodeFlags, TNodeType} from '../interfaces/node';
|
||||
@@ -76,7 +76,7 @@ export function ɵɵcontrolCreate(): void {
|
||||
initializeNativeControl(lView, tNode, fieldDirective);
|
||||
}
|
||||
|
||||
fieldDirective.ɵregister();
|
||||
fieldDirective.registerAsBinding(getCustomControl(tNode, lView));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,14 +291,14 @@ function isNativeControlFirstCreatePass(tNode: TNode): boolean {
|
||||
* @param tNode The `TNode` of the element to check.
|
||||
* @param lView The `LView` that contains the element.
|
||||
*/
|
||||
function getFieldDirective<T>(tNode: TNode, lView: LView): ɵFormFieldDirective<T> | null {
|
||||
function getFieldDirective<T>(tNode: TNode, lView: LView): ɵFormFieldDirective<T> | undefined {
|
||||
const index = tNode.fieldIndex;
|
||||
return index === -1 ? null : lView[index];
|
||||
return index === -1 ? undefined : lView[index];
|
||||
}
|
||||
|
||||
function getCustomControl(tNode: TNode, lView: LView): ɵCustomControl | null {
|
||||
function getCustomControl(tNode: TNode, lView: LView): ɵFormFieldBindingOptions | undefined {
|
||||
const index = tNode.customControlIndex;
|
||||
return index === -1 ? null : lView[index];
|
||||
return index === -1 ? undefined : lView[index];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -361,10 +361,6 @@ function initializeCustomControl(
|
||||
wrapListener(tNode, lView, () => fieldDirective.state().markAsTouched()),
|
||||
);
|
||||
}
|
||||
|
||||
const customControl = lView[directiveIndex] as ɵCustomControl;
|
||||
fieldDirective.focus = () =>
|
||||
customControl.focus ? customControl.focus() : fieldDirective.element.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -379,7 +375,6 @@ function initializeInteropControl(fieldDirective: ɵFormFieldDirective<unknown>)
|
||||
fieldDirective.state().setControlValue(value),
|
||||
);
|
||||
interopControl.registerOnTouched(() => fieldDirective.state().markAsTouched());
|
||||
fieldDirective.focus = () => fieldDirective.element.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -472,8 +467,6 @@ function initializeNativeControl(
|
||||
|
||||
storeCleanupWithContext(tView, lView, observer, observer.disconnect);
|
||||
}
|
||||
|
||||
fieldDirective.focus = () => element.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -42,8 +42,6 @@ export interface ɵFormFieldDirective<T> {
|
||||
/** A reference to the interoperable control, if one is present. */
|
||||
readonly ɵinteropControl: ɵInteropControl | undefined;
|
||||
|
||||
focus?(): void;
|
||||
|
||||
/**
|
||||
* Registers this directive as a control of its associated form field.
|
||||
*
|
||||
@@ -52,10 +50,12 @@ export interface ɵFormFieldDirective<T> {
|
||||
* the component will forward the bound field to another field directive in its own template,
|
||||
* and do nothing.
|
||||
*/
|
||||
ɵregister(): void;
|
||||
registerAsBinding(bindingOptions?: ɵFormFieldBindingOptions): void;
|
||||
}
|
||||
|
||||
export interface ɵCustomControl {
|
||||
/** A custom UI control for signal forms. */
|
||||
export interface ɵFormFieldBindingOptions {
|
||||
/** Focuses the custom control. */
|
||||
focus?(): void;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import {InputSignal, InputSignalWithTransform, ModelSignal, OutputRef} from '@angular/core';
|
||||
import type {FormFieldBindingOptions} from './form_field_directive';
|
||||
import {ValidationError, type WithOptionalField} from './rules/validation/validation_errors';
|
||||
import type {DisabledReason} from './types';
|
||||
|
||||
@@ -17,10 +18,6 @@ import type {DisabledReason} from './types';
|
||||
* @experimental 21.0.0
|
||||
*/
|
||||
export interface FormUiControl {
|
||||
// TODO: `ValidationError` and `DisabledReason` are inherently tied to the signal forms system.
|
||||
// They don't make sense when using a control separately from the forms system and setting the
|
||||
// inputs individually. Given that, should they still be part of this interface?
|
||||
|
||||
/**
|
||||
* An input to receive the errors for the field. If implemented, the `Field` directive will
|
||||
* automatically bind errors from the bound field to this input.
|
||||
@@ -119,8 +116,23 @@ export interface FormUiControl {
|
||||
readonly pattern?:
|
||||
| InputSignal<readonly RegExp[]>
|
||||
| InputSignalWithTransform<readonly RegExp[], unknown>;
|
||||
/**
|
||||
* Focuses the UI control.
|
||||
*
|
||||
* If the focus method is not implemented, Signal Forms will attempt to focus the host element
|
||||
* when asked to focus this control.
|
||||
*/
|
||||
focus?(): void;
|
||||
}
|
||||
|
||||
// Verify that `FormUiControl` implements `FormFieldBindingOptions`.
|
||||
// We intend for this to be the case so that a `FormUiControl` can act as its own `FormFieldBindingOptions`.
|
||||
// However, we don't want to add it as an actual `extends` clause to avoid confusing users.
|
||||
type Check<T extends true> = T;
|
||||
type FormUiControlImplementsFormFieldBindingOptions = Check<
|
||||
FormUiControl extends FormFieldBindingOptions ? true : false
|
||||
>;
|
||||
|
||||
/**
|
||||
* A contract for a form control that edits a `FieldTree` of type `TValue`. Any component that
|
||||
* implements this contract can be used with the `Field` directive.
|
||||
|
||||
@@ -16,17 +16,32 @@ import {
|
||||
InjectionToken,
|
||||
Injector,
|
||||
input,
|
||||
ɵRuntimeError as RuntimeError,
|
||||
signal,
|
||||
untracked,
|
||||
ɵcontrolUpdate as updateControlBinding,
|
||||
ɵCONTROL,
|
||||
ɵInteropControl,
|
||||
type ɵFormFieldBindingOptions,
|
||||
type ɵFormFieldDirective,
|
||||
} from '@angular/core';
|
||||
import {NG_VALUE_ACCESSOR, NgControl} from '@angular/forms';
|
||||
import {InteropNgControl} from '../controls/interop_ng_control';
|
||||
import {SignalFormsErrorCode} from '../errors';
|
||||
import {SIGNAL_FORMS_CONFIG} from '../field/di';
|
||||
import type {FieldNode} from '../field/node';
|
||||
import type {FieldTree} from './types';
|
||||
|
||||
export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions {
|
||||
/**
|
||||
* Focuses the binding.
|
||||
*
|
||||
* If not specified, Signal Forms will attempt to focus the host element of the `FormField` when
|
||||
* asked to focus this binding.
|
||||
*/
|
||||
focus?: VoidFunction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight DI token provided by the {@link FormField} directive.
|
||||
*
|
||||
@@ -79,6 +94,7 @@ export class FormField<T> {
|
||||
readonly injector = inject(Injector);
|
||||
readonly formField = input.required<FieldTree<T>>();
|
||||
readonly state = computed(() => this.formField()());
|
||||
private readonly bindingOptions = signal<FormFieldBindingOptions | undefined>(undefined);
|
||||
|
||||
readonly [ɵCONTROL] = controlInstructions;
|
||||
|
||||
@@ -109,8 +125,21 @@ export class FormField<T> {
|
||||
return (this.interopNgControl ??= new InteropNgControl(this.state));
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
ɵregister() {
|
||||
/**
|
||||
* Registers this `FormField` as a binding on its associated `FieldState`.
|
||||
*
|
||||
* This method should be called at most once for a given `FormField`. A `FormField` placed on a
|
||||
* custom control (`FormUiControl`) automatically registers that custom control as a binding.
|
||||
*/
|
||||
registerAsBinding(bindingOptions?: FormFieldBindingOptions) {
|
||||
if (untracked(this.bindingOptions)) {
|
||||
throw new RuntimeError(
|
||||
SignalFormsErrorCode.BINDING_ALREADY_REGISTERED,
|
||||
ngDevMode && 'FormField already registered as a binding',
|
||||
);
|
||||
}
|
||||
|
||||
this.bindingOptions.set(bindingOptions);
|
||||
// Register this control on the field state it is currently bound to. We do this at the end of
|
||||
// initialization so that it only runs if we are actually syncing with this control
|
||||
// (as opposed to just passing the field state through to its `formField` input).
|
||||
@@ -132,7 +161,14 @@ export class FormField<T> {
|
||||
}
|
||||
|
||||
/** Focuses this UI control. */
|
||||
focus?(): void;
|
||||
focus() {
|
||||
const bindingOptions = untracked(this.bindingOptions);
|
||||
if (bindingOptions?.focus) {
|
||||
bindingOptions.focus();
|
||||
} else {
|
||||
this.element.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We can't add `implements ɵFormFieldDirective<T>` to `Field` even though it should conform to the interface.
|
||||
|
||||
@@ -25,4 +25,5 @@ export const enum SignalFormsErrorCode {
|
||||
UNKNOWN_STATUS = 1910,
|
||||
COMPAT_NO_CHILDREN = 1911,
|
||||
MANAGED_METADATA_LAZY_CREATION = 1912,
|
||||
BINDING_ALREADY_REGISTERED = 1913,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,16 @@
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import {ApplicationRef, Component, input, model, signal} from '@angular/core';
|
||||
import {
|
||||
ApplicationRef,
|
||||
Component,
|
||||
inject,
|
||||
input,
|
||||
model,
|
||||
signal,
|
||||
viewChild,
|
||||
type ElementRef,
|
||||
} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {FormControl} from '@angular/forms';
|
||||
import {compatForm} from '../../compat';
|
||||
@@ -182,6 +191,38 @@ describe('FieldState focus behavior', () => {
|
||||
await act(() => fixture.componentInstance.f().focusBoundControl());
|
||||
expect(document.activeElement).toBe(focusedEl);
|
||||
});
|
||||
|
||||
it('should focus a manually registered form field binding', async () => {
|
||||
@Component({
|
||||
selector: 'custom-control',
|
||||
template: `<input #input />`,
|
||||
})
|
||||
class CustomControl {
|
||||
formField = input.required<FieldTree<string>>();
|
||||
input = viewChild.required<ElementRef<HTMLInputElement>>('input');
|
||||
|
||||
constructor() {
|
||||
inject(FormField, {self: true, optional: true})?.registerAsBinding({
|
||||
focus: () => this.input().nativeElement.focus(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [FormField, CustomControl],
|
||||
template: `<custom-control [formField]="f" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal(''));
|
||||
}
|
||||
|
||||
const fixture = await act(() => TestBed.createComponent(TestCmp));
|
||||
const nativeInput = fixture.nativeElement.querySelector('custom-control > input');
|
||||
expect(nativeInput).toBeTruthy();
|
||||
|
||||
await act(() => fixture.componentInstance.f().focusBoundControl());
|
||||
expect(document.activeElement).toBe(nativeInput);
|
||||
});
|
||||
});
|
||||
|
||||
async function act<T>(fn: () => T): Promise<T> {
|
||||
|
||||
@@ -3310,6 +3310,33 @@ describe('field directive', () => {
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
expect(fixture.componentInstance.f().formFieldBindings()).toHaveSize(0);
|
||||
});
|
||||
|
||||
it(`should manually register pass-through instance as a form field binding`, () => {
|
||||
@Component({
|
||||
selector: 'complex-control',
|
||||
template: ``,
|
||||
})
|
||||
class ComplexControl {
|
||||
readonly formField = input.required<FieldTree<string>>();
|
||||
|
||||
constructor() {
|
||||
inject(FormField, {optional: true, self: true})?.registerAsBinding();
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `<complex-control [formField]="f" />`,
|
||||
imports: [ComplexControl, FormField],
|
||||
})
|
||||
class TestCmp {
|
||||
f = form(signal('test'));
|
||||
formField = viewChild.required(FormField);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const instance = fixture.componentInstance;
|
||||
expect(instance.f().formFieldBindings()).toEqual([instance.formField()]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should synchronize disabled reasons', () => {
|
||||
|
||||
Reference in New Issue
Block a user