diff --git a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js index 63bc8535118..9934dcbfdc2 100644 --- a/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js +++ b/packages/compiler-cli/test/compliance/test_cases/r3_view_compiler_bindings/control_bindings/control_bindings.js @@ -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) { diff --git a/packages/compiler/src/template/pipeline/src/phases/var_counting.ts b/packages/compiler/src/template/pipeline/src/phases/var_counting.ts index 0e13658c0b7..efc01304cd3 100644 --- a/packages/compiler/src/template/pipeline/src/phases/var_counting.ts +++ b/packages/compiler/src/template/pipeline/src/phases/var_counting.ts @@ -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; diff --git a/packages/core/src/render3/instructions/control.ts b/packages/core/src/render3/instructions/control.ts index 8e640d60e49..09352f172fa 100644 --- a/packages/core/src/render3/instructions/control.ts +++ b/packages/core/src/render3/instructions/control.ts @@ -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(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( @@ -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, component: unknown, + bindings: ControlBindings, + state: ɵFieldState, + 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): 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, + 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, + 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 = 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, Signal | 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, 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. * diff --git a/packages/core/src/render3/interfaces/control.ts b/packages/core/src/render3/interfaces/control.ts index 8462c3a603d..5cb14f6a4f5 100644 --- a/packages/core/src/render3/interfaces/control.ts +++ b/packages/core/src/render3/interfaces/control.ts @@ -114,7 +114,7 @@ export interface ɵFieldState { /** * A signal indicating the patterns the field must match. */ - readonly pattern?: Signal; + readonly pattern: Signal; /** * A signal indicating whether the field is currently readonly. @@ -124,7 +124,7 @@ export interface ɵFieldState { /** * A signal indicating whether the field is required. */ - readonly required?: Signal; + readonly required: Signal; /** * A signal indicating whether the field has been touched by the user. diff --git a/packages/forms/signals/src/field/node.ts b/packages/forms/signals/src/field/node.ts index e9fa8481ed3..c9127de7a54 100644 --- a/packages/forms/signals/src/field/node.ts +++ b/packages/forms/signals/src/field/node.ts @@ -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 { return this.metadataOrUndefined(MIN_LENGTH); } - get pattern(): Signal | undefined { - return this.metadataOrUndefined(PATTERN); + get pattern(): Signal { + return this.metadataOrUndefined(PATTERN) ?? EMPTY; } - get required(): Signal | undefined { - return this.metadataOrUndefined(REQUIRED); + get required(): Signal { + return this.metadataOrUndefined(REQUIRED) ?? FALSE; } metadata(key: AggregateMetadataKey): Signal; @@ -254,6 +255,9 @@ export class FieldNode implements FieldState { } } +const EMPTY = computed(() => []); +const FALSE = computed(() => false); + /** * Field node of a field that has children. * This simplifies and makes certain types cleaner. diff --git a/packages/forms/signals/test/web/field_directive.spec.ts b/packages/forms/signals/test/web/field_directive.spec.ts index 17eee250e09..c65fe466bed 100644 --- a/packages/forms/signals/test/web/field_directive.spec.ts +++ b/packages/forms/signals/test/web/field_directive.spec.ts @@ -59,6 +59,53 @@ describe('field directive', () => { }); }); + describe('field input', () => { + it('should bind new field to control when changed', () => { + @Component({ + imports: [Field], + template: ``, + }) + 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: ``, + }) + 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: ``, + }) + 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 { + readonly value = model(''); + readonly disabled = input(true); + } + + @Component({ + imports: [Field, CustomControl], + template: ``, + }) + 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: ``, + }) + 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 { + readonly value = model(''); + readonly readonly = input(true); + } + + @Component({ + imports: [Field, CustomControl], + template: ``, + }) + 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: ``, + template: ``, }) 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 { readonly value = model(''); - readonly required = input(true); + readonly required = input(true); } @Component({ imports: [Field, CustomControl], - template: ``, + template: ``, }) 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: ``, @@ -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: ``, + template: ``, }) 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 { readonly value = model(0); - readonly max = input(123); + readonly max = input(); } @Component({ imports: [Field, CustomControl], - template: ``, + template: ``, }) 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: ``, @@ -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: ``, + template: ``, }) 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 { readonly value = model(0); - readonly min = input(123); + readonly min = input(); } @Component({ imports: [Field, CustomControl], - template: ``, + template: ``, }) 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: ``, @@ -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: ``, + template: ``, }) 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 { readonly value = model(''); - readonly maxLength = input(123); + readonly maxLength = input(); } @Component({ imports: [Field, CustomControl], - template: ``, + template: ``, }) 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: ``, + template: ``, }) 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 { readonly value = model(''); - readonly minLength = input(123); + readonly minLength = input(); } @Component({ imports: [Field, CustomControl], - template: ``, + template: ``, }) 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 { readonly value = model(''); - readonly pattern = input([/abc/]); + readonly pattern = input([]); } @Component({ imports: [Field, CustomControl], - template: ``, + template: ``, }) 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'); });