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:
Miles Malerba
2026-01-15 11:03:28 -08:00
committed by GitHub
parent f2cf96b519
commit 5974cd0afc
10 changed files with 146 additions and 26 deletions
@@ -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;
}
+16 -4
View File
@@ -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.
+1
View File
@@ -25,4 +25,5 @@ export const enum SignalFormsErrorCode {
UNKNOWN_STATUS = 1910,
COMPAT_NO_CHILDREN = 1911,
MANAGED_METADATA_LAZY_CREATION = 1912,
BINDING_ALREADY_REGISTERED = 1913,
}
+42 -1
View File
@@ -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', () => {