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:
@@ -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