Skip to content

Commit 95c3864

Browse files
SkyZeroZxamishne
authored andcommitted
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.
1 parent e55260f commit 95c3864

File tree

7 files changed

+76
-18
lines changed

7 files changed

+76
-18
lines changed

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
131131
// (undocumented)
132132
readonly errors: Signal<ValidationError.WithField[]>;
133133
readonly errorSummary: Signal<ValidationError.WithField[]>;
134-
focusBoundControl(): void;
134+
focusBoundControl(options?: FocusOptions): void;
135135
readonly formFieldBindings: Signal<readonly FormField<unknown>[]>;
136136
readonly hidden: Signal<boolean>;
137137
readonly invalid: Signal<boolean>;
@@ -176,7 +176,7 @@ export class FormField<T> {
176176
};
177177
// (undocumented)
178178
readonly element: HTMLElement;
179-
focus(): void;
179+
focus(options?: FocusOptions): void;
180180
// (undocumented)
181181
readonly formField: i0.InputSignal<FieldTree<T>>;
182182
protected getOrCreateNgControl(): InteropNgControl;
@@ -193,7 +193,7 @@ export class FormField<T> {
193193

194194
// @public (undocumented)
195195
export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions {
196-
focus?: VoidFunction;
196+
focus?(options?: FocusOptions): void;
197197
}
198198

199199
// @public
@@ -210,7 +210,7 @@ export interface FormUiControl {
210210
readonly disabled?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
211211
readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]> | InputSignalWithTransform<readonly WithOptionalField<DisabledReason>[], unknown>;
212212
readonly errors?: InputSignal<readonly WithOptionalField<ValidationError>[]> | InputSignalWithTransform<readonly WithOptionalField<ValidationError>[], unknown>;
213-
focus?(): void;
213+
focus?(options?: FocusOptions): void;
214214
readonly hidden?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
215215
readonly invalid?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
216216
readonly max?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export interface ɵFormFieldDirective<T> {
5656
/** A custom UI control for signal forms. */
5757
export interface ɵFormFieldBindingOptions {
5858
/** Focuses the custom control. */
59-
focus?(): void;
59+
focus?(options?: FocusOptions): void;
6060
}
6161

6262
/** Mirrors the `ControlValueAccessor` interface for interoperability. */

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ export interface FormUiControl {
122122
* If the focus method is not implemented, Signal Forms will attempt to focus the host element
123123
* when asked to focus this control.
124124
*/
125-
focus?(): void;
125+
focus?(options?: FocusOptions): void;
126126
}
127127

128128
// Verify that `FormUiControl` implements `FormFieldBindingOptions`.

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions {
3939
* If not specified, Signal Forms will attempt to focus the host element of the `FormField` when
4040
* asked to focus this binding.
4141
*/
42-
focus?: VoidFunction;
42+
focus?(options?: FocusOptions): void;
4343
}
4444

4545
/**
@@ -161,12 +161,12 @@ export class FormField<T> {
161161
}
162162

163163
/** Focuses this UI control. */
164-
focus() {
164+
focus(options?: FocusOptions) {
165165
const bindingOptions = untracked(this.bindingOptions);
166166
if (bindingOptions?.focus) {
167-
bindingOptions.focus();
167+
bindingOptions.focus(options);
168168
} else {
169-
this.element.focus();
169+
this.element.focus(options);
170170
}
171171
}
172172
}

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -225,8 +225,10 @@ export type MaybeFieldTree<TModel, TKey extends string | number = string | numbe
225225
* @category structure
226226
* @experimental 21.0.0
227227
*/
228-
export interface FieldState<TValue, TKey extends string | number = string | number>
229-
extends ɵFieldState<TValue> {
228+
export interface FieldState<
229+
TValue,
230+
TKey extends string | number = string | number,
231+
> extends ɵFieldState<TValue> {
230232
/**
231233
* A signal indicating whether field value has been changed by user.
232234
*/
@@ -314,8 +316,9 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
314316
/**
315317
* Focuses the first UI control in the DOM that is bound to this field state.
316318
* If no UI control is bound, does nothing.
319+
* @param options Optional focus options to pass to the native focus() method.
317320
*/
318-
focusBoundControl(): void;
321+
focusBoundControl(options?: FocusOptions): void;
319322
}
320323

321324
/**

packages/forms/signals/src/field/node.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ export class FieldNode implements FieldState<unknown> {
8080
this.submitState = new FieldSubmitState(this);
8181
}
8282

83-
focusBoundControl(): void {
84-
this.getBindingForFocus()?.focus();
83+
focusBoundControl(options?: FocusOptions): void {
84+
this.getBindingForFocus()?.focus(options);
8585
}
8686

8787
/**
@@ -92,11 +92,19 @@ export class FieldNode implements FieldState<unknown> {
9292
* the first one in the DOM. If no focusable bindings exist on this node, it will return the
9393
* first focusable binding in the DOM for any descendant node of this one.
9494
*/
95-
private getBindingForFocus(): (FormField<unknown> & {focus: VoidFunction}) | undefined {
95+
private getBindingForFocus():
96+
| (FormField<unknown> & {focus: (options?: FocusOptions) => void})
97+
| undefined {
9698
// First try to focus one of our own bindings.
9799
const own = this.formFieldBindings()
98-
.filter((b): b is FormField<unknown> & {focus: VoidFunction} => b.focus !== undefined)
99-
.reduce(firstInDom<FormField<unknown> & {focus: VoidFunction}>, undefined);
100+
.filter(
101+
(b): b is FormField<unknown> & {focus: (options?: FocusOptions) => void} =>
102+
b.focus !== undefined,
103+
)
104+
.reduce(
105+
firstInDom<FormField<unknown> & {focus: (options?: FocusOptions) => void}>,
106+
undefined,
107+
);
100108
if (own) return own;
101109
// Fallback to focusing the bound control for one of our children.
102110
return this.structure

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,53 @@ describe('FieldState focus behavior', () => {
223223
await act(() => fixture.componentInstance.f().focusBoundControl());
224224
expect(document.activeElement).toBe(nativeInput);
225225
});
226+
227+
it('should pass focus options to native control', async () => {
228+
@Component({
229+
imports: [FormField],
230+
template: `<input [formField]="f" />`,
231+
})
232+
class TestCmp {
233+
readonly f = form(signal(''));
234+
}
235+
236+
const fixture = await act(() => TestBed.createComponent(TestCmp));
237+
const input = fixture.nativeElement.firstChild as HTMLInputElement;
238+
239+
const focusSpy = spyOn(input, 'focus');
240+
241+
await act(() => fixture.componentInstance.f().focusBoundControl({preventScroll: true}));
242+
expect(focusSpy).toHaveBeenCalledWith({preventScroll: true});
243+
});
244+
245+
it('should pass focus options to custom control with focus method', async () => {
246+
let receivedOptions: FocusOptions | undefined;
247+
248+
@Component({
249+
selector: 'custom-control',
250+
host: {'tabindex': '-1'},
251+
template: '',
252+
})
253+
class CustomControl {
254+
readonly value = model<string>();
255+
focus(options?: FocusOptions) {
256+
receivedOptions = options;
257+
}
258+
}
259+
260+
@Component({
261+
imports: [FormField, CustomControl],
262+
template: `<custom-control [formField]="f" />`,
263+
})
264+
class TestCmp {
265+
readonly f = form(signal(''));
266+
}
267+
268+
const fixture = await act(() => TestBed.createComponent(TestCmp));
269+
270+
await act(() => fixture.componentInstance.f().focusBoundControl({preventScroll: true}));
271+
expect(receivedOptions).toEqual({preventScroll: true});
272+
});
226273
});
227274

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

0 commit comments

Comments
 (0)