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)
|
// (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. */
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user