mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
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:
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user