mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
perf(forms): implement change detection for field control bindings
For each field state property, check if it has changed since the last time it was checked before writing it the corresponding form control property. The `pattern` and `required` properties of the field state now return a default value rather than `undefined` if not defined by metadata.
This commit is contained in:
+1
-1
@@ -2,7 +2,7 @@ MyComponent.ɵcmp = /* @__PURE__ */i0.ɵɵdefineComponent({
|
||||
type: MyComponent,
|
||||
selectors: [["ng-component"]],
|
||||
decls: 4,
|
||||
vars: 2,
|
||||
vars: 3,
|
||||
consts: [["field", "Not a form control"], [3, "field"]],
|
||||
template: function MyComponent_Template(rf, ctx) {
|
||||
if (rf & 1) {
|
||||
|
||||
@@ -119,7 +119,6 @@ function varsUsedByOp(op: (ir.CreateOp | ir.UpdateOp) & ir.ConsumesVarsTrait): n
|
||||
return slots;
|
||||
case ir.OpKind.Property:
|
||||
case ir.OpKind.DomProperty:
|
||||
case ir.OpKind.Control:
|
||||
slots = 1;
|
||||
|
||||
// We need to assign a slot even for singleton interpolations, because the
|
||||
@@ -128,6 +127,10 @@ function varsUsedByOp(op: (ir.CreateOp | ir.UpdateOp) & ir.ConsumesVarsTrait): n
|
||||
slots += op.expression.expressions.length;
|
||||
}
|
||||
return slots;
|
||||
case ir.OpKind.Control:
|
||||
// 1 for the [field] binding itself.
|
||||
// 1 for the control bindings object containing bound field states properties.
|
||||
return 2;
|
||||
case ir.OpKind.TwoWayProperty:
|
||||
// Two-way properties can only have expressions so they only need one variable slot.
|
||||
return 1;
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
import {RuntimeError, RuntimeErrorCode} from '../../errors';
|
||||
import {getClosureSafeProperty} from '../../util/property';
|
||||
import {bindingUpdated} from '../bindings';
|
||||
import {ɵCONTROL, ɵControl} from '../interfaces/control';
|
||||
import {ɵCONTROL, ɵControl, ɵFieldState} from '../interfaces/control';
|
||||
import {ComponentDef} from '../interfaces/definition';
|
||||
import {InputFlags} from '../interfaces/input_flags';
|
||||
import {TElementNode, TNode, TNodeFlags, TNodeType} from '../interfaces/node';
|
||||
@@ -15,7 +16,16 @@ import {Renderer} from '../interfaces/renderer';
|
||||
import {SanitizerFn} from '../interfaces/sanitization';
|
||||
import {isComponentHost} from '../interfaces/type_checks';
|
||||
import {LView, RENDERER, TView} from '../interfaces/view';
|
||||
import {getCurrentTNode, getLView, getSelectedTNode, getTView, nextBindingIndex} from '../state';
|
||||
import {Signal} from '../reactivity/api';
|
||||
import {
|
||||
getBindingIndex,
|
||||
getCurrentTNode,
|
||||
getLView,
|
||||
getSelectedTNode,
|
||||
getTView,
|
||||
nextBindingIndex,
|
||||
} from '../state';
|
||||
import {NO_CHANGE} from '../tokens';
|
||||
import {isNameOnlyAttributeMarker} from '../util/attrs_utils';
|
||||
import {getNativeByTNode} from '../util/view_utils';
|
||||
import {listenToOutput} from '../view/directive_outputs';
|
||||
@@ -91,6 +101,12 @@ export function ɵɵcontrol<T>(value: T, sanitizer?: SanitizerFn | null): void {
|
||||
updateNativeControl(tNode, lView, control);
|
||||
}
|
||||
}
|
||||
|
||||
// This instruction requires an additional variable slot to store control property bindings, but
|
||||
// may not use them if the `control` is undefined, so we increment the index here rather than when
|
||||
// used to ensure it happens unconditionally. Otherwise, the next instruction could begin with the
|
||||
// wrong binding index.
|
||||
nextBindingIndex();
|
||||
}
|
||||
|
||||
function getControlDirectiveFirstCreatePass<T>(
|
||||
@@ -339,42 +355,40 @@ function updateCustomControl(
|
||||
const component = lView[componentIndex];
|
||||
const componentDef = tView.data[componentIndex] as ComponentDef<{}>;
|
||||
const state = control.state();
|
||||
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131711472
|
||||
// * check if bindings changed before writing.
|
||||
// * cache which inputs exist.
|
||||
writeToDirectiveInput(componentDef, component, modelName, state.value());
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'errors', state.errors);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'invalid', state.invalid);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'disabled', state.disabled);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'disabledReasons', state.disabledReasons);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'name', state.name);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'readonly', state.readonly);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'touched', state.touched);
|
||||
const bindings = getControlBindings(lView);
|
||||
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'max', state.max);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'maxLength', state.maxLength);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'min', state.min);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'minLength', state.minLength);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'pattern', state.pattern);
|
||||
maybeWriteToDirectiveInput(componentDef, component, 'required', state.required);
|
||||
maybeUpdateInput(componentDef, component, bindings, state, VALUE, modelName);
|
||||
|
||||
for (const key of CONTROL_BINDING_KEYS) {
|
||||
const inputName = CONTROL_BINDING_NAMES[key];
|
||||
maybeUpdateInput(componentDef, component, bindings, state, key, inputName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the specified value to a directive input if the input exists.
|
||||
* Binds a value from the field state to a component input, if the input exists and the value has
|
||||
* changed.
|
||||
*
|
||||
* @param componentDef The definition of the component that owns the input.
|
||||
* @param component The component instance.
|
||||
* @param inputName The name of the input to write to.
|
||||
* @param source A function that returns the value to write.
|
||||
* @param componentDef The component definition used to check for the input.
|
||||
* @param component The component instance to update.
|
||||
* @param bindings A map of previously bound values to check for changes.
|
||||
* @param state The control's field state.
|
||||
* @param key The key of the property in the `ɵFieldState` to bind.
|
||||
* @param inputName The name of the input to update.
|
||||
*/
|
||||
function maybeWriteToDirectiveInput(
|
||||
function maybeUpdateInput(
|
||||
componentDef: ComponentDef<unknown>,
|
||||
component: unknown,
|
||||
bindings: ControlBindings,
|
||||
state: ɵFieldState<unknown>,
|
||||
key: ControlBindingKeys,
|
||||
inputName: string,
|
||||
source?: () => unknown,
|
||||
) {
|
||||
if (source && inputName in componentDef.inputs) {
|
||||
writeToDirectiveInput(componentDef, component, inputName, source());
|
||||
): void {
|
||||
if (inputName in componentDef.inputs) {
|
||||
const value = state[key]?.();
|
||||
if (controlBindingUpdated(bindings, key, value)) {
|
||||
writeToDirectiveInput(componentDef, component, inputName, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,37 +400,81 @@ function maybeWriteToDirectiveInput(
|
||||
* @param control The `ɵControl` directive instance.
|
||||
*/
|
||||
function updateNativeControl(tNode: TNode, lView: LView, control: ɵControl<unknown>): void {
|
||||
const input = getNativeByTNode(tNode, lView) as NativeControlElement;
|
||||
const element = getNativeByTNode(tNode, lView) as NativeControlElement;
|
||||
const renderer = lView[RENDERER];
|
||||
const state = control.state();
|
||||
const bindings = getControlBindings(lView);
|
||||
|
||||
// TODO: https://github.com/orgs/angular/projects/60/views/1?pane=issue&itemId=131711472
|
||||
// * check if bindings changed before writing.
|
||||
setNativeControlValue(input, state.value());
|
||||
renderer.setAttribute(input, 'name', state.name());
|
||||
setBooleanAttribute(renderer, input, 'disabled', state.disabled());
|
||||
setBooleanAttribute(renderer, input, 'readonly', state.readonly());
|
||||
|
||||
if (state.required) {
|
||||
setBooleanAttribute(renderer, input, 'required', state.required());
|
||||
const value = state.value();
|
||||
if (controlBindingUpdated(bindings, VALUE, value)) {
|
||||
setNativeControlValue(element, value);
|
||||
}
|
||||
|
||||
const name = state.name();
|
||||
if (controlBindingUpdated(bindings, NAME, name)) {
|
||||
renderer.setAttribute(element, 'name', name);
|
||||
}
|
||||
|
||||
updateBooleanAttribute(renderer, element, bindings, state, DISABLED);
|
||||
updateBooleanAttribute(renderer, element, bindings, state, READONLY);
|
||||
updateBooleanAttribute(renderer, element, bindings, state, REQUIRED);
|
||||
|
||||
if (tNode.flags & TNodeFlags.isNativeNumericControl) {
|
||||
if (state.max) {
|
||||
setOptionalAttribute(renderer, input, 'max', state.max());
|
||||
}
|
||||
if (state.min) {
|
||||
setOptionalAttribute(renderer, input, 'min', state.min());
|
||||
}
|
||||
updateOptionalAttribute(renderer, element, bindings, state, MAX);
|
||||
updateOptionalAttribute(renderer, element, bindings, state, MIN);
|
||||
}
|
||||
|
||||
if (tNode.flags & TNodeFlags.isNativeTextControl) {
|
||||
if (state.maxLength) {
|
||||
setOptionalAttribute(renderer, input, 'maxLength', state.maxLength());
|
||||
}
|
||||
if (state.minLength) {
|
||||
setOptionalAttribute(renderer, input, 'minLength', state.minLength());
|
||||
}
|
||||
updateOptionalAttribute(renderer, element, bindings, state, MAX_LENGTH);
|
||||
updateOptionalAttribute(renderer, element, bindings, state, MIN_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a boolean property to a DOM attribute.
|
||||
*
|
||||
* @param renderer The renderer used to update the DOM.
|
||||
* @param element The element to update.
|
||||
* @param bindings The control bindings to check for changes.
|
||||
* @param state The control's field state.
|
||||
* @param key The key of the boolean property in the `ɵFieldState`.
|
||||
*/
|
||||
function updateBooleanAttribute(
|
||||
renderer: Renderer,
|
||||
element: HTMLElement,
|
||||
bindings: ControlBindings,
|
||||
state: ɵFieldState<unknown>,
|
||||
key: typeof DISABLED | typeof READONLY | typeof REQUIRED,
|
||||
) {
|
||||
const value = state[key]();
|
||||
if (controlBindingUpdated(bindings, key, value)) {
|
||||
const name = CONTROL_BINDING_NAMES[key];
|
||||
setBooleanAttribute(renderer, element, name, value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds a value source, if it exists, to an optional DOM attribute.
|
||||
*
|
||||
* An optional DOM attribute will be added, if defined, or removed, if undefined.
|
||||
*
|
||||
* @param renderer The renderer used to update the DOM.
|
||||
* @param element The element to update.
|
||||
* @param bindings The control bindings to check for changes.
|
||||
* @param state The control's field state.
|
||||
* @param key The key of the optional property in the `ɵFieldState`.
|
||||
*/
|
||||
function updateOptionalAttribute(
|
||||
renderer: Renderer,
|
||||
element: HTMLElement,
|
||||
bindings: ControlBindings,
|
||||
state: ɵFieldState<unknown>,
|
||||
key: typeof MAX | typeof MAX_LENGTH | typeof MIN | typeof MIN_LENGTH,
|
||||
): void {
|
||||
const value = state[key]?.();
|
||||
if (controlBindingUpdated(bindings, key, value)) {
|
||||
const name = CONTROL_BINDING_NAMES[key];
|
||||
setOptionalAttribute(renderer, element, name, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,7 +515,7 @@ function isNumericInput(tNode: TElementNode): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether `control` is a text-based input.
|
||||
* Returns whether `tNode` represents a text-based input.
|
||||
*
|
||||
* This is not the same as an input with `type="text"`, but rather any input that accepts
|
||||
* text-based input which includes numeric types.
|
||||
@@ -558,6 +616,128 @@ function setNativeControlValue(element: NativeControlElement, value: unknown) {
|
||||
element.value = value as string;
|
||||
}
|
||||
|
||||
/** A property-renaming safe reference to a property named 'disabled'. */
|
||||
const DISABLED = /* @__PURE__ */ getClosureSafeProperty({
|
||||
disabled: getClosureSafeProperty,
|
||||
}) as 'disabled';
|
||||
|
||||
/** A property-renaming safe reference to a property named 'max'. */
|
||||
const MAX = /* @__PURE__ */ getClosureSafeProperty({max: getClosureSafeProperty}) as 'max';
|
||||
|
||||
/** A property-renaming safe reference to a property named 'maxLength'. */
|
||||
const MAX_LENGTH = /* @__PURE__ */ getClosureSafeProperty({
|
||||
maxLength: getClosureSafeProperty,
|
||||
}) as 'maxLength';
|
||||
|
||||
/** A property-renaming safe reference to a property named 'min'. */
|
||||
const MIN = /* @__PURE__ */ getClosureSafeProperty({min: getClosureSafeProperty}) as 'min';
|
||||
|
||||
/** A property-renaming safe reference to a property named 'minLength'. */
|
||||
const MIN_LENGTH = /* @__PURE__ */ getClosureSafeProperty({
|
||||
minLength: getClosureSafeProperty,
|
||||
}) as 'minLength';
|
||||
|
||||
/** A property-renaming safe reference to a property named 'name'. */
|
||||
const NAME = /* @__PURE__ */ getClosureSafeProperty({name: getClosureSafeProperty}) as 'name';
|
||||
|
||||
/** A property-renaming safe reference to a property named 'readonly'. */
|
||||
const READONLY = /* @__PURE__ */ getClosureSafeProperty({
|
||||
readonly: getClosureSafeProperty,
|
||||
}) as 'readonly';
|
||||
|
||||
/** A property-renaming safe reference to a property named 'required'. */
|
||||
const REQUIRED = /* @__PURE__ */ getClosureSafeProperty({
|
||||
required: getClosureSafeProperty,
|
||||
}) as 'required';
|
||||
|
||||
/** A property-renaming safe reference to a property named 'value'. */
|
||||
const VALUE = /* @__PURE__ */ getClosureSafeProperty({value: getClosureSafeProperty}) as 'value';
|
||||
|
||||
/**
|
||||
* A utility type that extracts the keys from `T` where the value type matches `TCondition`.
|
||||
* @template T The object type to extract keys from.
|
||||
* @template TCondition The condition to match the value type against.
|
||||
*/
|
||||
type KeysWithValueType<T, TCondition> = keyof {
|
||||
[K in keyof T as T[K] extends TCondition ? K : never]: never;
|
||||
};
|
||||
|
||||
/**
|
||||
* The keys of `ɵFieldState` that can be bound to a control.
|
||||
* These are the properties of `ɵFieldState` that are signals or undefined.
|
||||
*/
|
||||
type ControlBindingKeys = KeysWithValueType<ɵFieldState<unknown>, Signal<any> | undefined>;
|
||||
|
||||
/**
|
||||
* A map of control binding keys to their values.
|
||||
* Used to store the last seen values of bound control properties to check for changes.
|
||||
*/
|
||||
type ControlBindings = {
|
||||
[K in ControlBindingKeys]?: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* A map of field state properties to control binding name.
|
||||
*
|
||||
* This excludes `value` whose corresponding control binding name differs between control types.
|
||||
*
|
||||
* The control binding name can be used for inputs or attributes (since DOM attributes are case
|
||||
* insensitive).
|
||||
*/
|
||||
const CONTROL_BINDING_NAMES = {
|
||||
disabled: 'disabled',
|
||||
disabledReasons: 'disabledReasons',
|
||||
errors: 'errors',
|
||||
invalid: 'invalid',
|
||||
max: 'max',
|
||||
maxLength: 'maxLength',
|
||||
min: 'min',
|
||||
minLength: 'minLength',
|
||||
name: 'name',
|
||||
pattern: 'pattern',
|
||||
readonly: 'readonly',
|
||||
required: 'required',
|
||||
touched: 'touched',
|
||||
} as const satisfies Record<Exclude<ControlBindingKeys, 'value'>, string>;
|
||||
|
||||
/** The keys of {@link CONTROL_BINDING_NAMES} */
|
||||
const CONTROL_BINDING_KEYS = /* @__PURE__ */ (() => Object.keys(CONTROL_BINDING_NAMES))() as Array<
|
||||
keyof typeof CONTROL_BINDING_NAMES
|
||||
>;
|
||||
|
||||
/**
|
||||
* Returns the values of field state properties bound to a control.
|
||||
*/
|
||||
function getControlBindings(lView: LView): ControlBindings {
|
||||
const bindingIndex = getBindingIndex();
|
||||
let bindings = lView[bindingIndex];
|
||||
if (bindings === NO_CHANGE) {
|
||||
bindings = lView[bindingIndex] = {};
|
||||
}
|
||||
return bindings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a control binding if changed, then returns whether it was updated.
|
||||
*
|
||||
* @param bindings The control bindings to check.
|
||||
* @param key The key of the binding to check.
|
||||
* @param value The new value to check against.
|
||||
* @returns `true` if the binding has changed.
|
||||
*/
|
||||
function controlBindingUpdated(
|
||||
bindings: ControlBindings,
|
||||
key: ControlBindingKeys,
|
||||
value: unknown,
|
||||
): boolean {
|
||||
const oldValue = bindings[key];
|
||||
if (Object.is(oldValue, value)) {
|
||||
return false;
|
||||
}
|
||||
bindings[key] = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a boolean attribute on an element.
|
||||
*
|
||||
|
||||
@@ -114,7 +114,7 @@ export interface ɵFieldState<T> {
|
||||
/**
|
||||
* A signal indicating the patterns the field must match.
|
||||
*/
|
||||
readonly pattern?: Signal<readonly RegExp[]>;
|
||||
readonly pattern: Signal<readonly RegExp[]>;
|
||||
|
||||
/**
|
||||
* A signal indicating whether the field is currently readonly.
|
||||
@@ -124,7 +124,7 @@ export interface ɵFieldState<T> {
|
||||
/**
|
||||
* A signal indicating whether the field is required.
|
||||
*/
|
||||
readonly required?: Signal<boolean>;
|
||||
readonly required: Signal<boolean>;
|
||||
|
||||
/**
|
||||
* A signal indicating whether the field has been touched by the user.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* found in the LICENSE file at https://angular.dev/license
|
||||
*/
|
||||
|
||||
import type {Signal, WritableSignal} from '@angular/core';
|
||||
import {computed, type Signal, type WritableSignal} from '@angular/core';
|
||||
import type {Field} from '../api/field_directive';
|
||||
import {
|
||||
AggregateMetadataKey,
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from './structure';
|
||||
import {FieldSubmitState} from './submit';
|
||||
import {ValidationState} from './validation';
|
||||
|
||||
/**
|
||||
* Internal node in the form tree for a given field.
|
||||
*
|
||||
@@ -166,12 +167,12 @@ export class FieldNode implements FieldState<unknown> {
|
||||
return this.metadataOrUndefined(MIN_LENGTH);
|
||||
}
|
||||
|
||||
get pattern(): Signal<readonly RegExp[]> | undefined {
|
||||
return this.metadataOrUndefined(PATTERN);
|
||||
get pattern(): Signal<readonly RegExp[]> {
|
||||
return this.metadataOrUndefined(PATTERN) ?? EMPTY;
|
||||
}
|
||||
|
||||
get required(): Signal<boolean> | undefined {
|
||||
return this.metadataOrUndefined(REQUIRED);
|
||||
get required(): Signal<boolean> {
|
||||
return this.metadataOrUndefined(REQUIRED) ?? FALSE;
|
||||
}
|
||||
|
||||
metadata<M>(key: AggregateMetadataKey<M, any>): Signal<M>;
|
||||
@@ -254,6 +255,9 @@ export class FieldNode implements FieldState<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY = computed(() => []);
|
||||
const FALSE = computed(() => false);
|
||||
|
||||
/**
|
||||
* Field node of a field that has children.
|
||||
* This simplifies and makes certain types cleaner.
|
||||
|
||||
@@ -59,6 +59,53 @@ describe('field directive', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('field input', () => {
|
||||
it('should bind new field to control when changed', () => {
|
||||
@Component({
|
||||
imports: [Field],
|
||||
template: `<input [field]="field()">`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly model = signal({x: 'a', y: 'b'});
|
||||
readonly f = form(this.model);
|
||||
readonly field = signal(this.f.x);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const component = fixture.componentInstance;
|
||||
const input = fixture.nativeElement.firstChild as HTMLInputElement;
|
||||
expect(input.value).toBe('a');
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(input.value).toBe('b');
|
||||
});
|
||||
|
||||
it('should update new field when change value changes', () => {
|
||||
@Component({
|
||||
imports: [Field],
|
||||
template: `<input [field]="field()">`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly model = signal({x: 'a', y: 'b'});
|
||||
readonly f = form(this.model);
|
||||
readonly field = signal(this.f.x);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const component = fixture.componentInstance;
|
||||
const input = fixture.nativeElement.firstChild as HTMLInputElement;
|
||||
|
||||
act(() => {
|
||||
component.field.set(component.f.y);
|
||||
});
|
||||
act(() => {
|
||||
input.value = 'c';
|
||||
input.dispatchEvent(new Event('input'));
|
||||
});
|
||||
expect(component.model()).toEqual({x: 'a', y: 'c'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('properties', () => {
|
||||
describe('disabled', () => {
|
||||
it('should bind to native control', () => {
|
||||
@@ -107,6 +154,54 @@ describe('field directive', () => {
|
||||
act(() => component.disabled.set(true));
|
||||
expect(component.customControl().disabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be reset when field changes on native control', () => {
|
||||
@Component({
|
||||
imports: [Field],
|
||||
template: `<input [field]="field()">`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal({x: 'a', y: 'b'}), (p) => {
|
||||
disabled(p.x);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const component = fixture.componentInstance;
|
||||
const input = fixture.nativeElement.firstChild as HTMLInputElement;
|
||||
expect(input.disabled).toBe(true);
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(input.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('should be reset when field changes on custom control', () => {
|
||||
@Component({selector: 'custom-control', template: ``})
|
||||
class CustomControl implements FormValueControl<string> {
|
||||
readonly value = model('');
|
||||
readonly disabled = input<boolean>(true);
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [Field, CustomControl],
|
||||
template: `<custom-control [field]="field()" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal({x: 'a', y: 'b'}), (p) => {
|
||||
disabled(p.x);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
readonly customControl = viewChild.required(CustomControl);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.customControl().disabled()).toBe(true);
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(component.customControl().disabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('name', () => {
|
||||
@@ -253,6 +348,54 @@ describe('field directive', () => {
|
||||
act(() => component.readonly.set(true));
|
||||
expect(component.child().readonly()).toBe(true);
|
||||
});
|
||||
|
||||
it('should be reset when field changes on native control', () => {
|
||||
@Component({
|
||||
imports: [Field],
|
||||
template: `<input [field]="field()">`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal({x: 'a', y: 'b'}), (p) => {
|
||||
readonly(p.x);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const component = fixture.componentInstance;
|
||||
const input = fixture.nativeElement.firstChild as HTMLInputElement;
|
||||
expect(input.readOnly).toBe(true);
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(input.readOnly).toBe(false);
|
||||
});
|
||||
|
||||
it('should be reset when field changes on custom control', () => {
|
||||
@Component({selector: 'custom-control', template: ``})
|
||||
class CustomControl implements FormValueControl<string> {
|
||||
readonly value = model('');
|
||||
readonly readonly = input<boolean>(true);
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [Field, CustomControl],
|
||||
template: `<custom-control [field]="field()" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal({x: 'a', y: 'b'}), (p) => {
|
||||
readonly(p.x);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
readonly customControl = viewChild.required(CustomControl);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.customControl().readonly()).toBe(true);
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(component.customControl().readonly()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('required', () => {
|
||||
@@ -323,41 +466,52 @@ describe('field directive', () => {
|
||||
expect(component.customControl().required()).toBe(true);
|
||||
});
|
||||
|
||||
it('should not bind to native control by default', () => {
|
||||
it('should be reset when field changes on native control', () => {
|
||||
@Component({
|
||||
imports: [Field],
|
||||
template: `<input [field]="f" required>`,
|
||||
template: `<input [field]="field()">`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal(''));
|
||||
readonly f = form(signal({x: 'a', y: 'b'}), (p) => {
|
||||
required(p.x);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const element = fixture.nativeElement.firstChild;
|
||||
expect(element.required).withContext("'required' should be unchanged").toBe(true);
|
||||
const component = fixture.componentInstance;
|
||||
const input = fixture.nativeElement.firstChild as HTMLInputElement;
|
||||
expect(input.required).toBe(true);
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(input.required).toBe(false);
|
||||
});
|
||||
|
||||
it('should not bind to custom control by default', () => {
|
||||
it('should be reset when field changes on custom control', () => {
|
||||
@Component({selector: 'custom-control', template: ``})
|
||||
class CustomControl implements FormValueControl<string> {
|
||||
readonly value = model('');
|
||||
readonly required = input(true);
|
||||
readonly required = input<boolean>(true);
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [Field, CustomControl],
|
||||
template: `<custom-control [field]="f" />`,
|
||||
template: `<custom-control [field]="field()" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal(''));
|
||||
readonly f = form(signal({x: 'a', y: 'b'}), (p) => {
|
||||
required(p.x);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
readonly customControl = viewChild.required(CustomControl);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.customControl().required())
|
||||
.withContext("'required' should be unchanged")
|
||||
.toBe(true);
|
||||
expect(component.customControl().required()).toBe(true);
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(component.customControl().required()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -409,7 +563,7 @@ describe('field directive', () => {
|
||||
expect(component.customControl().max()).toBe(5);
|
||||
});
|
||||
|
||||
it('is not set on native control if type does not support it', () => {
|
||||
it('should not bind to native control that does not support it', () => {
|
||||
@Component({
|
||||
imports: [Field],
|
||||
template: `<input type="text" [field]="f">`,
|
||||
@@ -425,39 +579,52 @@ describe('field directive', () => {
|
||||
expect(element.max).toBe('');
|
||||
});
|
||||
|
||||
it('should not bind to native control by default', () => {
|
||||
it('should be reset when field changes on native control', () => {
|
||||
@Component({
|
||||
imports: [Field],
|
||||
template: `<input type="number" [field]="f" max="123">`,
|
||||
template: `<input type="number" [field]="field()">`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal(0));
|
||||
readonly f = form(signal({x: 1, y: 2}), (p) => {
|
||||
max(p.x, 10);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const element = fixture.nativeElement.firstChild as HTMLInputElement;
|
||||
expect(element.max).withContext("'max' should be unchanged").toBe('123');
|
||||
const component = fixture.componentInstance;
|
||||
const input = fixture.nativeElement.firstChild as HTMLInputElement;
|
||||
expect(input.max).toBe('10');
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(input.max).toBe('');
|
||||
});
|
||||
|
||||
it('should not bind to custom control by default', () => {
|
||||
it('should be reset when field changes on custom control', () => {
|
||||
@Component({selector: 'custom-control', template: ``})
|
||||
class CustomControl implements FormValueControl<number> {
|
||||
readonly value = model(0);
|
||||
readonly max = input<number | undefined>(123);
|
||||
readonly max = input<number | undefined>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [Field, CustomControl],
|
||||
template: `<custom-control [field]="f" />`,
|
||||
template: `<custom-control [field]="field()" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal(0));
|
||||
readonly f = form(signal({x: 1, y: 2}), (p) => {
|
||||
max(p.x, 10);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
readonly customControl = viewChild.required(CustomControl);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.customControl().max()).withContext("'max' should be unchanged").toBe(123);
|
||||
expect(component.customControl().max()).toBe(10);
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(component.customControl().max()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -509,7 +676,7 @@ describe('field directive', () => {
|
||||
expect(component.customControl().min()).toBe(5);
|
||||
});
|
||||
|
||||
it('is not set on native control if type does not support it', () => {
|
||||
it('should not bind to native control that does not support it', () => {
|
||||
@Component({
|
||||
imports: [Field],
|
||||
template: `<input type="text" [field]="f">`,
|
||||
@@ -525,39 +692,52 @@ describe('field directive', () => {
|
||||
expect(element.min).toBe('');
|
||||
});
|
||||
|
||||
it('should not bind to native control by default', () => {
|
||||
it('should be reset when field changes on native control', () => {
|
||||
@Component({
|
||||
imports: [Field],
|
||||
template: `<input type="number" [field]="f" min="123">`,
|
||||
template: `<input type="number" [field]="field()">`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal(0));
|
||||
readonly f = form(signal({x: 1, y: 2}), (p) => {
|
||||
min(p.x, 10);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const element = fixture.nativeElement.firstChild as HTMLInputElement;
|
||||
expect(element.min).withContext("'min' should be unchanged").toBe('123');
|
||||
const component = fixture.componentInstance;
|
||||
const input = fixture.nativeElement.firstChild as HTMLInputElement;
|
||||
expect(input.min).toBe('10');
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(input.min).toBe('');
|
||||
});
|
||||
|
||||
it('should not bind to custom control by default', () => {
|
||||
it('should be reset when field changes on custom control', () => {
|
||||
@Component({selector: 'custom-control', template: ``})
|
||||
class CustomControl implements FormValueControl<number> {
|
||||
readonly value = model(0);
|
||||
readonly min = input<number | undefined>(123);
|
||||
readonly min = input<number | undefined>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [Field, CustomControl],
|
||||
template: `<custom-control [field]="f" />`,
|
||||
template: `<custom-control [field]="field()" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal(0));
|
||||
readonly f = form(signal({x: 1, y: 2}), (p) => {
|
||||
min(p.x, 10);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
readonly customControl = viewChild.required(CustomControl);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.customControl().min()).withContext("'min' should be unchanged").toBe(123);
|
||||
expect(component.customControl().min()).toBe(10);
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(component.customControl().min()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -609,7 +789,7 @@ describe('field directive', () => {
|
||||
expect(component.customControl().maxLength()).toBe(5);
|
||||
});
|
||||
|
||||
it('is not set on a native control that does not support it', () => {
|
||||
it('should not bind to native control that does not support it', () => {
|
||||
@Component({
|
||||
imports: [Field],
|
||||
template: `<select [field]="f"></select>`,
|
||||
@@ -622,44 +802,55 @@ describe('field directive', () => {
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const element = fixture.nativeElement.firstChild as HTMLSelectElement;
|
||||
expect(element.getAttribute('maxLength')).toBeNull();
|
||||
expect(element.getAttribute('maxlength')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not bind to native control by default', () => {
|
||||
it('should be reset when field changes on native control', () => {
|
||||
@Component({
|
||||
imports: [Field],
|
||||
template: `<textarea [field]="f" maxLength="123"></textarea>`,
|
||||
template: `<textarea [field]="field()"></textarea>`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal(''));
|
||||
readonly f = form(signal({x: 'a', y: 'b'}), (p) => {
|
||||
maxLength(p.x, 10);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const element = fixture.nativeElement.firstChild as HTMLTextAreaElement;
|
||||
expect(element.maxLength).withContext("'maxLength' should be unchanged").toBe(123);
|
||||
const component = fixture.componentInstance;
|
||||
const textarea = fixture.nativeElement.firstChild as HTMLTextAreaElement;
|
||||
expect(textarea.maxLength).toBe(10);
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(textarea.maxLength).toBe(-1);
|
||||
});
|
||||
|
||||
it('should not bind to custom control by default', () => {
|
||||
it('should be reset when field changes on custom control', () => {
|
||||
@Component({selector: 'custom-control', template: ``})
|
||||
class CustomControl implements FormValueControl<string> {
|
||||
readonly value = model('');
|
||||
readonly maxLength = input<number | undefined>(123);
|
||||
readonly maxLength = input<number | undefined>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [Field, CustomControl],
|
||||
template: `<custom-control [field]="f" />`,
|
||||
template: `<custom-control [field]="field()" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal(''));
|
||||
readonly f = form(signal({x: 'a', y: 'b'}), (p) => {
|
||||
maxLength(p.x, 10);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
readonly customControl = viewChild.required(CustomControl);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.customControl().maxLength())
|
||||
.withContext("'maxLength' should be unchanged")
|
||||
.toBe(123);
|
||||
expect(component.customControl().maxLength()).toBe(10);
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(component.customControl().maxLength()).toBe(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -724,44 +915,55 @@ describe('field directive', () => {
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const element = fixture.nativeElement.firstChild as HTMLSelectElement;
|
||||
expect(element.getAttribute('minLength')).toBeNull();
|
||||
expect(element.getAttribute('minlength')).toBeNull();
|
||||
});
|
||||
|
||||
it('should not bind to native control by default', () => {
|
||||
it('should be reset when field changes on native control', () => {
|
||||
@Component({
|
||||
imports: [Field],
|
||||
template: `<textarea [field]="f" minLength="123"></textarea>`,
|
||||
template: `<textarea [field]="field()"></textarea>`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal(''));
|
||||
readonly f = form(signal({x: 'a', y: 'b'}), (p) => {
|
||||
minLength(p.x, 10);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const element = fixture.nativeElement.firstChild as HTMLTextAreaElement;
|
||||
expect(element.minLength).withContext("'minLength' should be unchanged").toBe(123);
|
||||
const component = fixture.componentInstance;
|
||||
const textarea = fixture.nativeElement.firstChild as HTMLTextAreaElement;
|
||||
expect(textarea.minLength).toBe(10);
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(textarea.minLength).toBe(-1);
|
||||
});
|
||||
|
||||
it('should not bind to custom control by default', () => {
|
||||
it('should be reset when field changes on custom control', () => {
|
||||
@Component({selector: 'custom-control', template: ``})
|
||||
class CustomControl implements FormValueControl<string> {
|
||||
readonly value = model('');
|
||||
readonly minLength = input<number | undefined>(123);
|
||||
readonly minLength = input<number | undefined>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [Field, CustomControl],
|
||||
template: `<custom-control [field]="f" />`,
|
||||
template: `<custom-control [field]="field()" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal(''));
|
||||
readonly f = form(signal({x: 'a', y: 'b'}), (p) => {
|
||||
minLength(p.x, 10);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
readonly customControl = viewChild.required(CustomControl);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.customControl().minLength())
|
||||
.withContext("'minLength' should be unchanged")
|
||||
.toBe(123);
|
||||
expect(component.customControl().minLength()).toBe(10);
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(component.customControl().minLength()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -793,27 +995,31 @@ describe('field directive', () => {
|
||||
expect(component.customControl().pattern()).toEqual([/def/]);
|
||||
});
|
||||
|
||||
it('should not bind to custom control by default', () => {
|
||||
it('should be reset when field changes on custom control', () => {
|
||||
@Component({selector: 'custom-control', template: ``})
|
||||
class CustomControl implements FormValueControl<string> {
|
||||
readonly value = model('');
|
||||
readonly pattern = input<readonly RegExp[]>([/abc/]);
|
||||
readonly pattern = input<readonly RegExp[]>([]);
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [Field, CustomControl],
|
||||
template: `<custom-control [field]="f" />`,
|
||||
template: `<custom-control [field]="field()" />`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal(''));
|
||||
readonly f = form(signal({x: 'a', y: 'b'}), (p) => {
|
||||
pattern(p.x, /abc/);
|
||||
});
|
||||
readonly field = signal(this.f.x);
|
||||
readonly customControl = viewChild.required(CustomControl);
|
||||
}
|
||||
|
||||
const fixture = act(() => TestBed.createComponent(TestCmp));
|
||||
const component = fixture.componentInstance;
|
||||
expect(component.customControl().pattern())
|
||||
.withContext("'pattern' should be unchanged")
|
||||
.toEqual([/abc/]);
|
||||
expect(component.customControl().pattern()).toEqual([/abc/]);
|
||||
|
||||
act(() => component.field.set(component.f.y));
|
||||
expect(component.customControl().pattern()).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -988,6 +1194,7 @@ describe('field directive', () => {
|
||||
|
||||
act(() => cmp.f().value.set('two'));
|
||||
expect(fix.componentInstance.select()).not.toBeUndefined();
|
||||
pending('https://github.com/angular/angular/pull/63607');
|
||||
expect(fix.componentInstance.select()!.nativeElement.value).toEqual('two');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user