From 95c386469c7a2f09dd731601c2061bdb10d25717 Mon Sep 17 00:00:00 2001 From: SkyZeroZx <73321943+SkyZeroZx@users.noreply.github.com> Date: Thu, 15 Jan 2026 16:08:01 -0500 Subject: [PATCH] feat(forms): Add passing focus options to form field Extends the `focus` method of form fields and custom controls to accept and propagate `FocusOptions`. This enables developers to control focus behavior more precisely, for example, preventing scrolling when focusing an element. --- goldens/public-api/forms/signals/index.api.md | 8 ++-- .../core/src/render3/interfaces/control.ts | 2 +- packages/forms/signals/src/api/control.ts | 2 +- .../signals/src/api/form_field_directive.ts | 8 ++-- packages/forms/signals/src/api/types.ts | 9 ++-- packages/forms/signals/src/field/node.ts | 18 +++++-- packages/forms/signals/test/web/focus.spec.ts | 47 +++++++++++++++++++ 7 files changed, 76 insertions(+), 18 deletions(-) diff --git a/goldens/public-api/forms/signals/index.api.md b/goldens/public-api/forms/signals/index.api.md index e7f29aefa4d..e63cceb2569 100644 --- a/goldens/public-api/forms/signals/index.api.md +++ b/goldens/public-api/forms/signals/index.api.md @@ -131,7 +131,7 @@ export interface FieldState; readonly errorSummary: Signal; - focusBoundControl(): void; + focusBoundControl(options?: FocusOptions): void; readonly formFieldBindings: Signal[]>; readonly hidden: Signal; readonly invalid: Signal; @@ -176,7 +176,7 @@ export class FormField { }; // (undocumented) readonly element: HTMLElement; - focus(): void; + focus(options?: FocusOptions): void; // (undocumented) readonly formField: i0.InputSignal>; protected getOrCreateNgControl(): InteropNgControl; @@ -193,7 +193,7 @@ export class FormField { // @public (undocumented) export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions { - focus?: VoidFunction; + focus?(options?: FocusOptions): void; } // @public @@ -210,7 +210,7 @@ export interface FormUiControl { readonly disabled?: InputSignal | InputSignalWithTransform; readonly disabledReasons?: InputSignal[]> | InputSignalWithTransform[], unknown>; readonly errors?: InputSignal[]> | InputSignalWithTransform[], unknown>; - focus?(): void; + focus?(options?: FocusOptions): void; readonly hidden?: InputSignal | InputSignalWithTransform; readonly invalid?: InputSignal | InputSignalWithTransform; readonly max?: InputSignal | InputSignalWithTransform; diff --git a/packages/core/src/render3/interfaces/control.ts b/packages/core/src/render3/interfaces/control.ts index a5ba2fd31d5..dbee9a86a8a 100644 --- a/packages/core/src/render3/interfaces/control.ts +++ b/packages/core/src/render3/interfaces/control.ts @@ -56,7 +56,7 @@ export interface ɵFormFieldDirective { /** A custom UI control for signal forms. */ export interface ɵFormFieldBindingOptions { /** Focuses the custom control. */ - focus?(): void; + focus?(options?: FocusOptions): void; } /** Mirrors the `ControlValueAccessor` interface for interoperability. */ diff --git a/packages/forms/signals/src/api/control.ts b/packages/forms/signals/src/api/control.ts index f28922d639b..a9edb75e7f7 100644 --- a/packages/forms/signals/src/api/control.ts +++ b/packages/forms/signals/src/api/control.ts @@ -122,7 +122,7 @@ export interface FormUiControl { * If the focus method is not implemented, Signal Forms will attempt to focus the host element * when asked to focus this control. */ - focus?(): void; + focus?(options?: FocusOptions): void; } // Verify that `FormUiControl` implements `FormFieldBindingOptions`. diff --git a/packages/forms/signals/src/api/form_field_directive.ts b/packages/forms/signals/src/api/form_field_directive.ts index 4c760fe68f3..94e568624f0 100644 --- a/packages/forms/signals/src/api/form_field_directive.ts +++ b/packages/forms/signals/src/api/form_field_directive.ts @@ -39,7 +39,7 @@ export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions { * If not specified, Signal Forms will attempt to focus the host element of the `FormField` when * asked to focus this binding. */ - focus?: VoidFunction; + focus?(options?: FocusOptions): void; } /** @@ -161,12 +161,12 @@ export class FormField { } /** Focuses this UI control. */ - focus() { + focus(options?: FocusOptions) { const bindingOptions = untracked(this.bindingOptions); if (bindingOptions?.focus) { - bindingOptions.focus(); + bindingOptions.focus(options); } else { - this.element.focus(); + this.element.focus(options); } } } diff --git a/packages/forms/signals/src/api/types.ts b/packages/forms/signals/src/api/types.ts index 479c0ee4e82..65a40a18b19 100644 --- a/packages/forms/signals/src/api/types.ts +++ b/packages/forms/signals/src/api/types.ts @@ -225,8 +225,10 @@ export type MaybeFieldTree - extends ɵFieldState { +export interface FieldState< + TValue, + TKey extends string | number = string | number, +> extends ɵFieldState { /** * A signal indicating whether field value has been changed by user. */ @@ -314,8 +316,9 @@ export interface FieldState { this.submitState = new FieldSubmitState(this); } - focusBoundControl(): void { - this.getBindingForFocus()?.focus(); + focusBoundControl(options?: FocusOptions): void { + this.getBindingForFocus()?.focus(options); } /** @@ -92,11 +92,19 @@ export class FieldNode implements FieldState { * the first one in the DOM. If no focusable bindings exist on this node, it will return the * first focusable binding in the DOM for any descendant node of this one. */ - private getBindingForFocus(): (FormField & {focus: VoidFunction}) | undefined { + private getBindingForFocus(): + | (FormField & {focus: (options?: FocusOptions) => void}) + | undefined { // First try to focus one of our own bindings. const own = this.formFieldBindings() - .filter((b): b is FormField & {focus: VoidFunction} => b.focus !== undefined) - .reduce(firstInDom & {focus: VoidFunction}>, undefined); + .filter( + (b): b is FormField & {focus: (options?: FocusOptions) => void} => + b.focus !== undefined, + ) + .reduce( + firstInDom & {focus: (options?: FocusOptions) => void}>, + undefined, + ); if (own) return own; // Fallback to focusing the bound control for one of our children. return this.structure diff --git a/packages/forms/signals/test/web/focus.spec.ts b/packages/forms/signals/test/web/focus.spec.ts index f5a5501e7a6..a40d7657f2e 100644 --- a/packages/forms/signals/test/web/focus.spec.ts +++ b/packages/forms/signals/test/web/focus.spec.ts @@ -223,6 +223,53 @@ describe('FieldState focus behavior', () => { await act(() => fixture.componentInstance.f().focusBoundControl()); expect(document.activeElement).toBe(nativeInput); }); + + it('should pass focus options to native control', async () => { + @Component({ + imports: [FormField], + template: ``, + }) + class TestCmp { + readonly f = form(signal('')); + } + + const fixture = await act(() => TestBed.createComponent(TestCmp)); + const input = fixture.nativeElement.firstChild as HTMLInputElement; + + const focusSpy = spyOn(input, 'focus'); + + await act(() => fixture.componentInstance.f().focusBoundControl({preventScroll: true})); + expect(focusSpy).toHaveBeenCalledWith({preventScroll: true}); + }); + + it('should pass focus options to custom control with focus method', async () => { + let receivedOptions: FocusOptions | undefined; + + @Component({ + selector: 'custom-control', + host: {'tabindex': '-1'}, + template: '', + }) + class CustomControl { + readonly value = model(); + focus(options?: FocusOptions) { + receivedOptions = options; + } + } + + @Component({ + imports: [FormField, CustomControl], + template: ``, + }) + class TestCmp { + readonly f = form(signal('')); + } + + const fixture = await act(() => TestBed.createComponent(TestCmp)); + + await act(() => fixture.componentInstance.f().focusBoundControl({preventScroll: true})); + expect(receivedOptions).toEqual({preventScroll: true}); + }); }); async function act(fn: () => T): Promise {