diff --git a/packages/core/src/render3/instructions/control.ts b/packages/core/src/render3/instructions/control.ts index eed6ab46297..12f7f681b73 100644 --- a/packages/core/src/render3/instructions/control.ts +++ b/packages/core/src/render3/instructions/control.ts @@ -568,45 +568,28 @@ function updateCustomControl( const state = control.state(); const bindings = getControlBindings(lView); - maybeUpdateInput(directiveDef, directive, bindings, state, CONTROL_VALUE, modelName); + // Bind custom form control model ('value' or 'checked'). + const controlValue = state.controlValue(); + if (controlBindingUpdated(bindings, CONTROL_VALUE, controlValue)) { + writeToDirectiveInput(directiveDef, directive, modelName, controlValue); + } + const isNative = (tNode.flags & TNodeFlags.isNativeControl) !== 0; + const element = isNative ? (getNativeByTNode(tNode, lView) as NativeControlElement) : null; + const renderer = lView[RENDERER]; + + // Bind remaining field state properties. for (const key of CONTROL_BINDING_KEYS) { - const inputName = CONTROL_BINDING_NAMES[key]; - maybeUpdateInput(directiveDef, directive, bindings, state, key, inputName); - } - - // If the host node is a native control, we can bind field state properties to attributes for any - // that weren't defined as inputs on the custom control. We can reuse the update path for native - // controls since any properties with a corresponding input would have just been checked above, - // and thus will appear unchanged. - if (tNode.flags & TNodeFlags.isNativeControl) { - updateNativeControl(tNode, lView, control); - } -} - -/** - * Binds a value from the field state to a component input, if the input exists and the value has - * changed. - * - * @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 maybeUpdateInput( - directiveDef: DirectiveDef, - directive: unknown, - bindings: ControlBindings, - state: ɵFieldState, - key: ControlBindingKeys, - inputName: string, -): void { - if (inputName in directiveDef.inputs) { const value = state[key]?.(); if (controlBindingUpdated(bindings, key, value)) { - writeToDirectiveInput(directiveDef, directive, inputName, value); + const inputName = CONTROL_BINDING_NAMES[key]; + updateDirectiveInputs(tNode, lView, inputName, value); + + // If the host node is a native control, we can bind field state properties to native + // properties for any that weren't defined as inputs on the custom control. + if (isNative && !(inputName in directiveDef.inputs)) { + updateNativeProperty(tNode, renderer, element!, key, value, inputName); + } } } } diff --git a/packages/forms/signals/test/web/field_directive.spec.ts b/packages/forms/signals/test/web/field_directive.spec.ts index c03b68f2599..0672f4d5e01 100644 --- a/packages/forms/signals/test/web/field_directive.spec.ts +++ b/packages/forms/signals/test/web/field_directive.spec.ts @@ -162,6 +162,36 @@ describe('field directive', () => { expect(comp.dir().dirty()).toBe(true); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly dirty = input.required(); + } + + @Component({ + selector: 'custom-control', + template: '', + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly data = signal(''); + readonly f = form(this.data); + readonly dir = viewChild.required(TestDir); + } + + const comp = act(() => TestBed.createComponent(TestCmp)).componentInstance; + expect(comp.dir().dirty()).toBe(false); + act(() => comp.f().markAsDirty()); + expect(comp.dir().dirty()).toBe(true); + }); + it('should be reset when field changes on custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -292,6 +322,41 @@ describe('field directive', () => { expect(dir.disabled()).toBe(true); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly disabled = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(false); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly disabled = signal(false); + readonly f = form(signal(false), (p) => { + disabled(p, this.disabled); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.disabled()).toBe(false); + expect(element.disabled).toBe(false); + + act(() => fixture.componentInstance.disabled.set(true)); + expect(dir.disabled()).toBe(true); + expect(element.disabled).toBe(true); + }); + it('should be reset when field changes on native control', () => { @Component({ imports: [Field], @@ -402,6 +467,45 @@ describe('field directive', () => { ]); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly disabledReasons = input.required[]>(); + } + + @Component({ + selector: 'custom-control', + template: '', + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly disabled = signal(false); + readonly f = form(signal(''), (p) => { + disabled(p, () => { + return this.disabled() ? 'b' : false; + }); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + + expect(dir.disabledReasons()).toEqual([]); + + act(() => fixture.componentInstance.disabled.set(true)); + expect(dir.disabledReasons()).toEqual([ + {message: 'b', fieldTree: fixture.componentInstance.f}, + ]); + }); + it('should be reset when field changes on custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -490,6 +594,41 @@ describe('field directive', () => { expect(dir.errors()).toEqual([requiredError({fieldTree: fixture.componentInstance.f})]); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly errors = input.required[]>(); + } + + @Component({ + selector: 'custom-control', + template: '', + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly required = signal(false); + readonly f = form(signal(''), (p) => { + required(p, {when: this.required}); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + + expect(dir.errors()).toEqual([]); + + act(() => fixture.componentInstance.required.set(true)); + expect(dir.errors()).toEqual([requiredError({fieldTree: fixture.componentInstance.f})]); + }); + it('should be reset when field changes on custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -578,6 +717,38 @@ describe('field directive', () => { expect(dir.hidden()).toBe(true); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly hidden = input.required(); + } + + @Component({selector: 'custom-control', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model.required(); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly hidden = signal(false); + readonly f = form(signal(''), (p) => { + hidden(p, this.hidden); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + + expect(dir.hidden()).toBe(false); + + act(() => fixture.componentInstance.hidden.set(true)); + expect(dir.hidden()).toBe(true); + }); + it('should be reset when field changes on custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -662,6 +833,38 @@ describe('field directive', () => { expect(dir.invalid()).toBe(true); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly invalid = input.required(); + } + + @Component({selector: 'custom-control', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model.required(); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly invalid = signal(false); + readonly f = form(signal(''), (p) => { + required(p, {when: this.invalid}); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + + expect(dir.invalid()).toBe(false); + + act(() => fixture.componentInstance.invalid.set(true)); + expect(dir.invalid()).toBe(true); + }); + it('should be reset when field changes on custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -787,6 +990,34 @@ describe('field directive', () => { expect(dir.name()).toBe('root'); }); + + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly name = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model.required(); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly f = form(signal(''), {name: 'root'}); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.name()).toBe('root'); + expect(element.name).toBe('root'); + }); }); describe('pending', () => { @@ -916,6 +1147,49 @@ describe('field directive', () => { await fixture.whenStable(); expect(dir.pending()).toBe(false); }); + + it('should bind to directive input on custom control', async () => { + const {promise, resolve} = promiseWithResolvers(); + + @Directive({selector: '[testDir]'}) + class TestDir { + readonly pending = input.required(); + } + + @Component({selector: 'custom-control', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model.required(); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly f = form(signal(''), (p) => { + validateAsync(p, { + params: () => [], + factory: (params) => + resource({ + params, + loader: () => promise, + }), + onSuccess: (results) => results, + onError: () => null, + }); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + + expect(dir.pending()).toBe(true); + resolve([]); + await promise; + await fixture.whenStable(); + expect(dir.pending()).toBe(false); + }); }); describe('readonly', () => { @@ -1019,6 +1293,41 @@ describe('field directive', () => { expect(dir.readonly()).toBe(true); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly readonly = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(''); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly readonly = signal(false); + readonly f = form(signal(''), (p) => { + readonly(p, this.readonly); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.readonly()).toBe(false); + expect(element.readOnly).toBe(false); + + act(() => fixture.componentInstance.readonly.set(true)); + expect(dir.readonly()).toBe(true); + expect(element.readOnly).toBe(true); + }); + it('should be reset when field changes on native control', () => { @Component({ imports: [Field], @@ -1169,6 +1478,41 @@ describe('field directive', () => { expect(dir.required()).toBe(true); }); + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly required = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(''); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly required = signal(false); + readonly f = form(signal(''), (p) => { + required(p, {when: this.required}); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.required()).toBe(false); + expect(element.required).toBe(false); + + act(() => fixture.componentInstance.required.set(true)); + expect(dir.required()).toBe(true); + expect(element.required).toBe(true); + }); + it('should be reset when field changes on native control', () => { @Component({ imports: [Field], @@ -1366,6 +1710,41 @@ describe('field directive', () => { act(() => fixture.componentInstance.max.set(5)); expect(dir.max()).toBe(5); }); + + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly max = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(0); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly max = signal(10); + readonly f = form(signal(5), (p) => { + max(p, this.max); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.max()).toBe(10); + expect(element.max).toBe('10'); + + act(() => fixture.componentInstance.max.set(5)); + expect(dir.max()).toBe(5); + expect(element.max).toBe('5'); + }); }); describe('min', () => { @@ -1516,6 +1895,41 @@ describe('field directive', () => { act(() => fixture.componentInstance.min.set(5)); expect(dir.min()).toBe(5); }); + + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly min = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(0); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly min = signal(10); + readonly f = form(signal(15), (p) => { + min(p, this.min); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.min()).toBe(10); + expect(element.min).toBe('10'); + + act(() => fixture.componentInstance.min.set(5)); + expect(dir.min()).toBe(5); + expect(element.min).toBe('5'); + }); }); describe('maxLength', () => { @@ -1682,6 +2096,41 @@ describe('field directive', () => { act(() => fixture.componentInstance.maxLength.set(5)); expect(dir.maxLength()).toBe(5); }); + + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly maxLength = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(''); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly maxLength = signal(10); + readonly f = form(signal(''), (p) => { + maxLength(p, this.maxLength); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.maxLength()).toBe(10); + expect(element.maxLength).toBe(10); + + act(() => fixture.componentInstance.maxLength.set(5)); + expect(dir.maxLength()).toBe(5); + expect(element.maxLength).toBe(5); + }); }); describe('minLength', () => { @@ -1848,6 +2297,41 @@ describe('field directive', () => { act(() => fixture.componentInstance.minLength.set(5)); expect(dir.minLength()).toBe(5); }); + + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly minLength = input.required(); + } + + @Component({selector: 'input[custom]', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(''); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly minLength = signal(10); + readonly f = form(signal('abc'), (p) => { + minLength(p, this.minLength); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + const element = fixture.nativeElement.querySelector('input') as HTMLInputElement; + + expect(dir.minLength()).toBe(10); + expect(element.minLength).toBe(10); + + act(() => fixture.componentInstance.minLength.set(5)); + expect(dir.minLength()).toBe(5); + expect(element.minLength).toBe(5); + }); }); describe('pattern', () => { @@ -1931,6 +2415,38 @@ describe('field directive', () => { act(() => fixture.componentInstance.pattern.set(/b*/)); expect(dir.pattern()).toEqual([/b*/]); }); + + it('should bind to directive input on custom control', () => { + @Directive({selector: '[testDir]'}) + class TestDir { + readonly pattern = input.required(); + } + + @Component({selector: 'custom-control', template: ``}) + class CustomControl implements FormValueControl { + readonly value = model(''); + } + + @Component({ + imports: [Field, TestDir, CustomControl], + template: ``, + }) + class TestCmp { + readonly pattern = signal(/a*/); + readonly f = form(signal('abc'), (p) => { + pattern(p, this.pattern); + }); + readonly dir = viewChild.required(TestDir); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const dir = fixture.componentInstance.dir(); + + expect(dir.pattern()).toEqual([/a*/]); + + act(() => fixture.componentInstance.pattern.set(/b*/)); + expect(dir.pattern()).toEqual([/b*/]); + }); }); });