Skip to content

Commit 5974cd0

Browse files
authored
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`.
1 parent f2cf96b commit 5974cd0

10 files changed

Lines changed: 146 additions & 26 deletions

File tree

goldens/public-api/forms/signals/compat/index.api.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { WritableSignal } from '@angular/core';
1919
import { ɵCONTROL } from '@angular/core';
2020
import { ɵcontrolUpdate } from '@angular/core';
2121
import { ɵFieldState } from '@angular/core';
22+
import { ɵFormFieldBindingOptions } from '@angular/core';
2223
import { ɵɵcontrolCreate } from '@angular/core';
2324

2425
// @public

goldens/public-api/forms/signals/index.api.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { WritableSignal } from '@angular/core';
2929
import { ɵCONTROL } from '@angular/core';
3030
import { ɵcontrolUpdate } from '@angular/core';
3131
import { ɵFieldState } from '@angular/core';
32+
import { ɵFormFieldBindingOptions } from '@angular/core';
3233
import { ɵɵcontrolCreate } from '@angular/core';
3334

3435
// @public
@@ -175,12 +176,13 @@ export class FormField<T> {
175176
};
176177
// (undocumented)
177178
readonly element: HTMLElement;
178-
focus?(): void;
179+
focus(): void;
179180
// (undocumented)
180181
readonly formField: i0.InputSignal<FieldTree<T>>;
181182
protected getOrCreateNgControl(): InteropNgControl;
182183
// (undocumented)
183184
readonly injector: Injector;
185+
registerAsBinding(bindingOptions?: FormFieldBindingOptions): void;
184186
// (undocumented)
185187
readonly state: i0.Signal<[T] extends [_angular_forms.AbstractControl<any, any, any>] ? CompatFieldState<T, string | number> : FieldState<T, string | number>>;
186188
// (undocumented)
@@ -189,6 +191,11 @@ export class FormField<T> {
189191
static ɵfac: i0.ɵɵFactoryDeclaration<FormField<any>, never>;
190192
}
191193

194+
// @public (undocumented)
195+
export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions {
196+
focus?: VoidFunction;
197+
}
198+
192199
// @public
193200
export interface FormOptions {
194201
adapter?: FieldAdapter;
@@ -203,6 +210,7 @@ export interface FormUiControl {
203210
readonly disabled?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
204211
readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]> | InputSignalWithTransform<readonly WithOptionalField<DisabledReason>[], unknown>;
205212
readonly errors?: InputSignal<readonly WithOptionalField<ValidationError>[]> | InputSignalWithTransform<readonly WithOptionalField<ValidationError>[], unknown>;
213+
focus?(): void;
206214
readonly hidden?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
207215
readonly invalid?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
208216
readonly max?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;

packages/core/src/core_render3_private_export.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ export {LContext as ɵLContext} from './render3/interfaces/context';
253253
export {
254254
ɵCONTROL,
255255
ɵFieldState,
256+
ɵFormFieldBindingOptions,
256257
ɵFormFieldDirective,
257258
ɵInteropControl,
258259
} from './render3/interfaces/control';

packages/core/src/render3/instructions/control.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
ɵCONTROL,
1515
ɵFieldState,
1616
ɵFormFieldDirective,
17-
type ɵCustomControl,
17+
type ɵFormFieldBindingOptions,
1818
} from '../interfaces/control';
1919
import {DirectiveDef} from '../interfaces/definition';
2020
import {TElementNode, TNode, TNodeFlags, TNodeType} from '../interfaces/node';
@@ -76,7 +76,7 @@ export function ɵɵcontrolCreate(): void {
7676
initializeNativeControl(lView, tNode, fieldDirective);
7777
}
7878

79-
fieldDirective.ɵregister();
79+
fieldDirective.registerAsBinding(getCustomControl(tNode, lView));
8080
}
8181

8282
/**
@@ -291,14 +291,14 @@ function isNativeControlFirstCreatePass(tNode: TNode): boolean {
291291
* @param tNode The `TNode` of the element to check.
292292
* @param lView The `LView` that contains the element.
293293
*/
294-
function getFieldDirective<T>(tNode: TNode, lView: LView): ɵFormFieldDirective<T> | null {
294+
function getFieldDirective<T>(tNode: TNode, lView: LView): ɵFormFieldDirective<T> | undefined {
295295
const index = tNode.fieldIndex;
296-
return index === -1 ? null : lView[index];
296+
return index === -1 ? undefined : lView[index];
297297
}
298298

299-
function getCustomControl(tNode: TNode, lView: LView): ɵCustomControl | null {
299+
function getCustomControl(tNode: TNode, lView: LView): ɵFormFieldBindingOptions | undefined {
300300
const index = tNode.customControlIndex;
301-
return index === -1 ? null : lView[index];
301+
return index === -1 ? undefined : lView[index];
302302
}
303303

304304
/**
@@ -361,10 +361,6 @@ function initializeCustomControl(
361361
wrapListener(tNode, lView, () => fieldDirective.state().markAsTouched()),
362362
);
363363
}
364-
365-
const customControl = lView[directiveIndex] as ɵCustomControl;
366-
fieldDirective.focus = () =>
367-
customControl.focus ? customControl.focus() : fieldDirective.element.focus();
368364
}
369365

370366
/**
@@ -379,7 +375,6 @@ function initializeInteropControl(fieldDirective: ɵFormFieldDirective<unknown>)
379375
fieldDirective.state().setControlValue(value),
380376
);
381377
interopControl.registerOnTouched(() => fieldDirective.state().markAsTouched());
382-
fieldDirective.focus = () => fieldDirective.element.focus();
383378
}
384379

385380
/**
@@ -472,8 +467,6 @@ function initializeNativeControl(
472467

473468
storeCleanupWithContext(tView, lView, observer, observer.disconnect);
474469
}
475-
476-
fieldDirective.focus = () => element.focus();
477470
}
478471

479472
/**

packages/core/src/render3/interfaces/control.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,6 @@ export interface ɵFormFieldDirective<T> {
4242
/** A reference to the interoperable control, if one is present. */
4343
readonly ɵinteropControl: ɵInteropControl | undefined;
4444

45-
focus?(): void;
46-
4745
/**
4846
* Registers this directive as a control of its associated form field.
4947
*
@@ -52,10 +50,12 @@ export interface ɵFormFieldDirective<T> {
5250
* the component will forward the bound field to another field directive in its own template,
5351
* and do nothing.
5452
*/
55-
ɵregister(): void;
53+
registerAsBinding(bindingOptions?: ɵFormFieldBindingOptions): void;
5654
}
5755

58-
export interface ɵCustomControl {
56+
/** A custom UI control for signal forms. */
57+
export interface ɵFormFieldBindingOptions {
58+
/** Focuses the custom control. */
5959
focus?(): void;
6060
}
6161

packages/forms/signals/src/api/control.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
*/
88

99
import {InputSignal, InputSignalWithTransform, ModelSignal, OutputRef} from '@angular/core';
10+
import type {FormFieldBindingOptions} from './form_field_directive';
1011
import {ValidationError, type WithOptionalField} from './rules/validation/validation_errors';
1112
import type {DisabledReason} from './types';
1213

@@ -17,10 +18,6 @@ import type {DisabledReason} from './types';
1718
* @experimental 21.0.0
1819
*/
1920
export interface FormUiControl {
20-
// TODO: `ValidationError` and `DisabledReason` are inherently tied to the signal forms system.
21-
// They don't make sense when using a control separately from the forms system and setting the
22-
// inputs individually. Given that, should they still be part of this interface?
23-
2421
/**
2522
* An input to receive the errors for the field. If implemented, the `Field` directive will
2623
* automatically bind errors from the bound field to this input.
@@ -119,8 +116,23 @@ export interface FormUiControl {
119116
readonly pattern?:
120117
| InputSignal<readonly RegExp[]>
121118
| InputSignalWithTransform<readonly RegExp[], unknown>;
119+
/**
120+
* Focuses the UI control.
121+
*
122+
* If the focus method is not implemented, Signal Forms will attempt to focus the host element
123+
* when asked to focus this control.
124+
*/
125+
focus?(): void;
122126
}
123127

128+
// Verify that `FormUiControl` implements `FormFieldBindingOptions`.
129+
// We intend for this to be the case so that a `FormUiControl` can act as its own `FormFieldBindingOptions`.
130+
// However, we don't want to add it as an actual `extends` clause to avoid confusing users.
131+
type Check<T extends true> = T;
132+
type FormUiControlImplementsFormFieldBindingOptions = Check<
133+
FormUiControl extends FormFieldBindingOptions ? true : false
134+
>;
135+
124136
/**
125137
* A contract for a form control that edits a `FieldTree` of type `TValue`. Any component that
126138
* implements this contract can be used with the `Field` directive.

packages/forms/signals/src/api/form_field_directive.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,32 @@ import {
1616
InjectionToken,
1717
Injector,
1818
input,
19+
ɵRuntimeError as RuntimeError,
20+
signal,
21+
untracked,
1922
ɵcontrolUpdate as updateControlBinding,
2023
ɵCONTROL,
2124
ɵInteropControl,
25+
type ɵFormFieldBindingOptions,
2226
type ɵFormFieldDirective,
2327
} from '@angular/core';
2428
import {NG_VALUE_ACCESSOR, NgControl} from '@angular/forms';
2529
import {InteropNgControl} from '../controls/interop_ng_control';
30+
import {SignalFormsErrorCode} from '../errors';
2631
import {SIGNAL_FORMS_CONFIG} from '../field/di';
2732
import type {FieldNode} from '../field/node';
2833
import type {FieldTree} from './types';
2934

35+
export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions {
36+
/**
37+
* Focuses the binding.
38+
*
39+
* If not specified, Signal Forms will attempt to focus the host element of the `FormField` when
40+
* asked to focus this binding.
41+
*/
42+
focus?: VoidFunction;
43+
}
44+
3045
/**
3146
* Lightweight DI token provided by the {@link FormField} directive.
3247
*
@@ -79,6 +94,7 @@ export class FormField<T> {
7994
readonly injector = inject(Injector);
8095
readonly formField = input.required<FieldTree<T>>();
8196
readonly state = computed(() => this.formField()());
97+
private readonly bindingOptions = signal<FormFieldBindingOptions | undefined>(undefined);
8298

8399
readonly [ɵCONTROL] = controlInstructions;
84100

@@ -109,8 +125,21 @@ export class FormField<T> {
109125
return (this.interopNgControl ??= new InteropNgControl(this.state));
110126
}
111127

112-
/** @internal */
113-
ɵregister() {
128+
/**
129+
* Registers this `FormField` as a binding on its associated `FieldState`.
130+
*
131+
* This method should be called at most once for a given `FormField`. A `FormField` placed on a
132+
* custom control (`FormUiControl`) automatically registers that custom control as a binding.
133+
*/
134+
registerAsBinding(bindingOptions?: FormFieldBindingOptions) {
135+
if (untracked(this.bindingOptions)) {
136+
throw new RuntimeError(
137+
SignalFormsErrorCode.BINDING_ALREADY_REGISTERED,
138+
ngDevMode && 'FormField already registered as a binding',
139+
);
140+
}
141+
142+
this.bindingOptions.set(bindingOptions);
114143
// Register this control on the field state it is currently bound to. We do this at the end of
115144
// initialization so that it only runs if we are actually syncing with this control
116145
// (as opposed to just passing the field state through to its `formField` input).
@@ -132,7 +161,14 @@ export class FormField<T> {
132161
}
133162

134163
/** Focuses this UI control. */
135-
focus?(): void;
164+
focus() {
165+
const bindingOptions = untracked(this.bindingOptions);
166+
if (bindingOptions?.focus) {
167+
bindingOptions.focus();
168+
} else {
169+
this.element.focus();
170+
}
171+
}
136172
}
137173

138174
// We can't add `implements ɵFormFieldDirective<T>` to `Field` even though it should conform to the interface.

packages/forms/signals/src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,5 @@ export const enum SignalFormsErrorCode {
2525
UNKNOWN_STATUS = 1910,
2626
COMPAT_NO_CHILDREN = 1911,
2727
MANAGED_METADATA_LAZY_CREATION = 1912,
28+
BINDING_ALREADY_REGISTERED = 1913,
2829
}

packages/forms/signals/test/web/focus.spec.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,16 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {ApplicationRef, Component, input, model, signal} from '@angular/core';
9+
import {
10+
ApplicationRef,
11+
Component,
12+
inject,
13+
input,
14+
model,
15+
signal,
16+
viewChild,
17+
type ElementRef,
18+
} from '@angular/core';
1019
import {TestBed} from '@angular/core/testing';
1120
import {FormControl} from '@angular/forms';
1221
import {compatForm} from '../../compat';
@@ -182,6 +191,38 @@ describe('FieldState focus behavior', () => {
182191
await act(() => fixture.componentInstance.f().focusBoundControl());
183192
expect(document.activeElement).toBe(focusedEl);
184193
});
194+
195+
it('should focus a manually registered form field binding', async () => {
196+
@Component({
197+
selector: 'custom-control',
198+
template: `<input #input />`,
199+
})
200+
class CustomControl {
201+
formField = input.required<FieldTree<string>>();
202+
input = viewChild.required<ElementRef<HTMLInputElement>>('input');
203+
204+
constructor() {
205+
inject(FormField, {self: true, optional: true})?.registerAsBinding({
206+
focus: () => this.input().nativeElement.focus(),
207+
});
208+
}
209+
}
210+
211+
@Component({
212+
imports: [FormField, CustomControl],
213+
template: `<custom-control [formField]="f" />`,
214+
})
215+
class TestCmp {
216+
readonly f = form(signal(''));
217+
}
218+
219+
const fixture = await act(() => TestBed.createComponent(TestCmp));
220+
const nativeInput = fixture.nativeElement.querySelector('custom-control > input');
221+
expect(nativeInput).toBeTruthy();
222+
223+
await act(() => fixture.componentInstance.f().focusBoundControl());
224+
expect(document.activeElement).toBe(nativeInput);
225+
});
185226
});
186227

187228
async function act<T>(fn: () => T): Promise<T> {

packages/forms/signals/test/web/form_field_directive.spec.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3310,6 +3310,33 @@ describe('field directive', () => {
33103310
const fixture = act(() => TestBed.createComponent(TestCmp));
33113311
expect(fixture.componentInstance.f().formFieldBindings()).toHaveSize(0);
33123312
});
3313+
3314+
it(`should manually register pass-through instance as a form field binding`, () => {
3315+
@Component({
3316+
selector: 'complex-control',
3317+
template: ``,
3318+
})
3319+
class ComplexControl {
3320+
readonly formField = input.required<FieldTree<string>>();
3321+
3322+
constructor() {
3323+
inject(FormField, {optional: true, self: true})?.registerAsBinding();
3324+
}
3325+
}
3326+
3327+
@Component({
3328+
template: `<complex-control [formField]="f" />`,
3329+
imports: [ComplexControl, FormField],
3330+
})
3331+
class TestCmp {
3332+
f = form(signal('test'));
3333+
formField = viewChild.required(FormField);
3334+
}
3335+
3336+
const fixture = act(() => TestBed.createComponent(TestCmp));
3337+
const instance = fixture.componentInstance;
3338+
expect(instance.f().formFieldBindings()).toEqual([instance.formField()]);
3339+
});
33133340
});
33143341

33153342
it('should synchronize disabled reasons', () => {

0 commit comments

Comments
 (0)