From 5974cd0afcdeaaeecc245e1a6759591d8eae3be6 Mon Sep 17 00:00:00 2001 From: Miles Malerba Date: Thu, 15 Jan 2026 11:03:28 -0800 Subject: [PATCH] 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`. --- .../forms/signals/compat/index.api.md | 1 + goldens/public-api/forms/signals/index.api.md | 10 ++++- .../core/src/core_render3_private_export.ts | 1 + .../core/src/render3/instructions/control.ts | 19 +++----- .../core/src/render3/interfaces/control.ts | 8 ++-- packages/forms/signals/src/api/control.ts | 20 +++++++-- .../signals/src/api/form_field_directive.ts | 42 ++++++++++++++++-- packages/forms/signals/src/errors.ts | 1 + packages/forms/signals/test/web/focus.spec.ts | 43 ++++++++++++++++++- .../test/web/form_field_directive.spec.ts | 27 ++++++++++++ 10 files changed, 146 insertions(+), 26 deletions(-) diff --git a/goldens/public-api/forms/signals/compat/index.api.md b/goldens/public-api/forms/signals/compat/index.api.md index 33f0094ef74..cb1c8bf349f 100644 --- a/goldens/public-api/forms/signals/compat/index.api.md +++ b/goldens/public-api/forms/signals/compat/index.api.md @@ -19,6 +19,7 @@ import { WritableSignal } from '@angular/core'; import { ɵCONTROL } from '@angular/core'; import { ɵcontrolUpdate } from '@angular/core'; import { ɵFieldState } from '@angular/core'; +import { ɵFormFieldBindingOptions } from '@angular/core'; import { ɵɵcontrolCreate } from '@angular/core'; // @public diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index 617de19fa9f..e7f29aefa4d 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -29,6 +29,7 @@ import { WritableSignal } from '@angular/core'; import { ɵCONTROL } from '@angular/core'; import { ɵcontrolUpdate } from '@angular/core'; import { ɵFieldState } from '@angular/core'; +import { ɵFormFieldBindingOptions } from '@angular/core'; import { ɵɵcontrolCreate } from '@angular/core'; // @public @@ -175,12 +176,13 @@ export class FormField { }; // (undocumented) readonly element: HTMLElement; - focus?(): void; + focus(): void; // (undocumented) readonly formField: i0.InputSignal>; protected getOrCreateNgControl(): InteropNgControl; // (undocumented) readonly injector: Injector; + registerAsBinding(bindingOptions?: FormFieldBindingOptions): void; // (undocumented) readonly state: i0.Signal<[T] extends [_angular_forms.AbstractControl] ? CompatFieldState : FieldState>; // (undocumented) @@ -189,6 +191,11 @@ export class FormField { static ɵfac: i0.ɵɵFactoryDeclaration, never>; } +// @public (undocumented) +export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions { + focus?: VoidFunction; +} + // @public export interface FormOptions { adapter?: FieldAdapter; @@ -203,6 +210,7 @@ export interface FormUiControl { readonly disabled?: InputSignal | InputSignalWithTransform; readonly disabledReasons?: InputSignal[]> | InputSignalWithTransform[], unknown>; readonly errors?: InputSignal[]> | InputSignalWithTransform[], unknown>; + focus?(): void; readonly hidden?: InputSignal | InputSignalWithTransform; readonly invalid?: InputSignal | InputSignalWithTransform; readonly max?: InputSignal | InputSignalWithTransform; diff --git a/packages/core/src/core_render3_private_export.ts b/packages/core/src/core_render3_private_export.ts index 19cc4dd7db5..3f184ce9127 100644 --- a/packages/core/src/core_render3_private_export.ts +++ b/packages/core/src/core_render3_private_export.ts @@ -253,6 +253,7 @@ export {LContext as ɵLContext} from './render3/interfaces/context'; export { ɵCONTROL, ɵFieldState, + ɵFormFieldBindingOptions, ɵFormFieldDirective, ɵInteropControl, } from './render3/interfaces/control'; diff --git a/packages/core/src/render3/instructions/control.ts b/packages/core/src/render3/instructions/control.ts index e73e3ce30ac..a5472509667 100644 --- a/packages/core/src/render3/instructions/control.ts +++ b/packages/core/src/render3/instructions/control.ts @@ -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(tNode: TNode, lView: LView): ɵFormFieldDirective | null { +function getFieldDirective(tNode: TNode, lView: LView): ɵFormFieldDirective | 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) 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(); } /** diff --git a/packages/core/src/render3/interfaces/control.ts b/packages/core/src/render3/interfaces/control.ts index 393b6031448..a5ba2fd31d5 100644 --- a/packages/core/src/render3/interfaces/control.ts +++ b/packages/core/src/render3/interfaces/control.ts @@ -42,8 +42,6 @@ export interface ɵFormFieldDirective { /** 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 { * 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; } diff --git a/packages/forms/signals/src/api/control.ts b/packages/forms/signals/src/api/control.ts index 4533810d7b4..f28922d639b 100644 --- a/packages/forms/signals/src/api/control.ts +++ b/packages/forms/signals/src/api/control.ts @@ -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 | InputSignalWithTransform; + /** + * 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; +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. diff --git a/packages/forms/signals/src/api/form_field_directive.ts b/packages/forms/signals/src/api/form_field_directive.ts index 91e8f72d1b6..4c760fe68f3 100644 --- a/packages/forms/signals/src/api/form_field_directive.ts +++ b/packages/forms/signals/src/api/form_field_directive.ts @@ -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 { readonly injector = inject(Injector); readonly formField = input.required>(); readonly state = computed(() => this.formField()()); + private readonly bindingOptions = signal(undefined); readonly [ɵCONTROL] = controlInstructions; @@ -109,8 +125,21 @@ export class FormField { 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 { } /** 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` to `Field` even though it should conform to the interface. diff --git a/packages/forms/signals/src/errors.ts b/packages/forms/signals/src/errors.ts index 9ff7cd95298..8d636993d86 100644 --- a/packages/forms/signals/src/errors.ts +++ b/packages/forms/signals/src/errors.ts @@ -25,4 +25,5 @@ export const enum SignalFormsErrorCode { UNKNOWN_STATUS = 1910, COMPAT_NO_CHILDREN = 1911, MANAGED_METADATA_LAZY_CREATION = 1912, + BINDING_ALREADY_REGISTERED = 1913, } diff --git a/packages/forms/signals/test/web/focus.spec.ts b/packages/forms/signals/test/web/focus.spec.ts index 9291839120d..f5a5501e7a6 100644 --- a/packages/forms/signals/test/web/focus.spec.ts +++ b/packages/forms/signals/test/web/focus.spec.ts @@ -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: ``, + }) + class CustomControl { + formField = input.required>(); + input = viewChild.required>('input'); + + constructor() { + inject(FormField, {self: true, optional: true})?.registerAsBinding({ + focus: () => this.input().nativeElement.focus(), + }); + } + } + + @Component({ + imports: [FormField, CustomControl], + template: ``, + }) + 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(fn: () => T): Promise { diff --git a/packages/forms/signals/test/web/form_field_directive.spec.ts b/packages/forms/signals/test/web/form_field_directive.spec.ts index e245dca2217..f8c52bb67ca 100644 --- a/packages/forms/signals/test/web/form_field_directive.spec.ts +++ b/packages/forms/signals/test/web/form_field_directive.spec.ts @@ -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>(); + + constructor() { + inject(FormField, {optional: true, self: true})?.registerAsBinding(); + } + } + + @Component({ + template: ``, + 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', () => {