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) // (undocumented)
readonly errors: Signal<ValidationError.WithField[]>; readonly errors: Signal<ValidationError.WithField[]>;
readonly errorSummary: Signal<ValidationError.WithField[]>; readonly errorSummary: Signal<ValidationError.WithField[]>;
focusBoundControl(): void; focusBoundControl(options?: FocusOptions): void;
readonly formFieldBindings: Signal<readonly FormField<unknown>[]>; readonly formFieldBindings: Signal<readonly FormField<unknown>[]>;
readonly hidden: Signal<boolean>; readonly hidden: Signal<boolean>;
readonly invalid: Signal<boolean>; readonly invalid: Signal<boolean>;
@@ -176,7 +176,7 @@ export class FormField<T> {
}; };
// (undocumented) // (undocumented)
readonly element: HTMLElement; readonly element: HTMLElement;
focus(): void; focus(options?: FocusOptions): void;
// (undocumented) // (undocumented)
readonly formField: i0.InputSignal<FieldTree<T>>; readonly formField: i0.InputSignal<FieldTree<T>>;
protected getOrCreateNgControl(): InteropNgControl; protected getOrCreateNgControl(): InteropNgControl;
@@ -193,7 +193,7 @@ export class FormField<T> {
// @public (undocumented) // @public (undocumented)
export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions { export interface FormFieldBindingOptions extends ɵFormFieldBindingOptions {
focus?: VoidFunction; focus?(options?: FocusOptions): void;
} }
// @public // @public
@@ -210,7 +210,7 @@ export interface FormUiControl {
readonly disabled?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>; readonly disabled?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]> | InputSignalWithTransform<readonly WithOptionalField<DisabledReason>[], unknown>; readonly disabledReasons?: InputSignal<readonly WithOptionalField<DisabledReason>[]> | InputSignalWithTransform<readonly WithOptionalField<DisabledReason>[], unknown>;
readonly errors?: InputSignal<readonly WithOptionalField<ValidationError>[]> | InputSignalWithTransform<readonly WithOptionalField<ValidationError>[], 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 hidden?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly invalid?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>; readonly invalid?: InputSignal<boolean> | InputSignalWithTransform<boolean, unknown>;
readonly max?: InputSignal<number | undefined> | InputSignalWithTransform<number | undefined, 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. */ /** A custom UI control for signal forms. */
export interface ɵFormFieldBindingOptions { export interface ɵFormFieldBindingOptions {
/** Focuses the custom control. */ /** Focuses the custom control. */
focus?(): void; focus?(options?: FocusOptions): void;
} }
/** Mirrors the `ControlValueAccessor` interface for interoperability. */ /** 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 * If the focus method is not implemented, Signal Forms will attempt to focus the host element
* when asked to focus this control. * when asked to focus this control.
*/ */
focus?(): void; focus?(options?: FocusOptions): void;
} }
// Verify that `FormUiControl` implements `FormFieldBindingOptions`. // 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 * If not specified, Signal Forms will attempt to focus the host element of the `FormField` when
* asked to focus this binding. * asked to focus this binding.
*/ */
focus?: VoidFunction; focus?(options?: FocusOptions): void;
} }
/** /**
@@ -161,12 +161,12 @@ export class FormField<T> {
} }
/** Focuses this UI control. */ /** Focuses this UI control. */
focus() { focus(options?: FocusOptions) {
const bindingOptions = untracked(this.bindingOptions); const bindingOptions = untracked(this.bindingOptions);
if (bindingOptions?.focus) { if (bindingOptions?.focus) {
bindingOptions.focus(); bindingOptions.focus(options);
} else { } 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 * @category structure
* @experimental 21.0.0 * @experimental 21.0.0
*/ */
export interface FieldState<TValue, TKey extends string | number = string | number> export interface FieldState<
extends ɵFieldState<TValue> { TValue,
TKey extends string | number = string | number,
> extends ɵFieldState<TValue> {
/** /**
* A signal indicating whether field value has been changed by user. * 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. * Focuses the first UI control in the DOM that is bound to this field state.
* If no UI control is bound, does nothing. * 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); this.submitState = new FieldSubmitState(this);
} }
focusBoundControl(): void { focusBoundControl(options?: FocusOptions): void {
this.getBindingForFocus()?.focus(); 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 * 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. * 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. // First try to focus one of our own bindings.
const own = this.formFieldBindings() const own = this.formFieldBindings()
.filter((b): b is FormField<unknown> & {focus: VoidFunction} => b.focus !== undefined) .filter(
.reduce(firstInDom<FormField<unknown> & {focus: VoidFunction}>, undefined); (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; if (own) return own;
// Fallback to focusing the bound control for one of our children. // Fallback to focusing the bound control for one of our children.
return this.structure return this.structure
@@ -223,6 +223,53 @@ describe('FieldState focus behavior', () => {
await act(() => fixture.componentInstance.f().focusBoundControl()); await act(() => fixture.componentInstance.f().focusBoundControl());
expect(document.activeElement).toBe(nativeInput); 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> { async function act<T>(fn: () => T): Promise<T> {