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.
This commit is contained in:
SkyZeroZx
2026-01-15 16:08:01 -05:00
committed by Alon Mishne
parent e55260f693
commit 95c386469c
7 changed files with 76 additions and 18 deletions
@@ -131,7 +131,7 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
// (undocumented)
readonly errors: Signal<ValidationError.WithField[]>;
readonly errorSummary: Signal<ValidationError.WithField[]>;
focusBoundControl(): void;
focusBoundControl(options?: FocusOptions): void;
readonly formFieldBindings: Signal<readonly FormField<unknown>[]>;
readonly hidden: Signal<boolean>;
readonly invalid: Signal<boolean>;
@@ -176,7 +176,7 @@ export class FormField<T> {
};
// (undocumented)
readonly element: HTMLElement;
focus(): void;
focus(options?: FocusOptions): void;
// (undocumented)
readonly formField: i0.InputSignal<FieldTree<T>>;
protected getOrCreateNgControl(): InteropNgControl;
@@ -193,7 +193,7 @@ export class FormField<T> {
// @public (undocumented)
export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions {
focus?: VoidFunction;
focus?(options?: FocusOptions): void;
}
// @public
@@ -210,7 +210,7 @@ export interface FormUiControl {
readonly disabled?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]> | InputSignalWithTransform<readonly WithOptionalField<DisabledReason>[], unknown>;
readonly errors?: InputSignal<readonly WithOptionalField<ValidationError>[]> | InputSignalWithTransform<readonly WithOptionalField<ValidationError>[], unknown>;
focus?(): void;
focus?(options?: FocusOptions): void;
readonly hidden?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly invalid?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly max?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, unknown>;
@@ -56,7 +56,7 @@ export interface ɵFormFieldDirective<T> {
/** 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. */
+1 -1
View File
@@ -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`.
@@ -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<T> {
}
/** 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);
}
}
}
+6 -3
View File
@@ -225,8 +225,10 @@ export type MaybeFieldTree<TModel, TKey extends string | number = string | numbe
* @category structure
* @experimental 21.0.0
*/
export interface FieldState<TValue, TKey extends string | number = string | number>
extends ɵFieldState<TValue> {
export interface FieldState<
TValue,
TKey extends string | number = string | number,
> extends ɵFieldState<TValue> {
/**
* A signal indicating whether field value has been changed by user.
*/
@@ -314,8 +316,9 @@ export interface FieldState<TValue, TKey extends string | number = string | numb
/**
* Focuses the first UI control in the DOM that is bound to this field state.
* If no UI control is bound, does nothing.
* @param options Optional focus options to pass to the native focus() method.
*/
focusBoundControl(): void;
focusBoundControl(options?: FocusOptions): void;
}
/**
+13 -5
View File
@@ -80,8 +80,8 @@ export class FieldNode implements FieldState<unknown> {
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<unknown> {
* 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<unknown> & {focus: VoidFunction}) | undefined {
private getBindingForFocus():
| (FormField<unknown> & {focus: (options?: FocusOptions) => void})
| undefined {
// First try to focus one of our own bindings.
const own = this.formFieldBindings()
.filter((b): b is FormField<unknown> & {focus: VoidFunction} => b.focus !== undefined)
.reduce(firstInDom<FormField<unknown> & {focus: VoidFunction}>, undefined);
.filter(
(b): b is FormField<unknown> & {focus: (options?: FocusOptions) => void} =>
b.focus !== undefined,
)
.reduce(
firstInDom<FormField<unknown> & {focus: (options?: FocusOptions) => void}>,
undefined,
);
if (own) return own;
// Fallback to focusing the bound control for one of our children.
return this.structure
@@ -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: `<input [formField]="f" />`,
})
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<string>();
focus(options?: FocusOptions) {
receivedOptions = options;
}
}
@Component({
imports: [FormField, CustomControl],
template: `<custom-control [formField]="f" />`,
})
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<T>(fn: () => T): Promise<T> {