From b5af15a3328833aadfb0eec5d5a41d507ac4e3c7 Mon Sep 17 00:00:00 2001 From: Leon Senft Date: Wed, 25 Mar 2026 13:35:02 -0700 Subject: [PATCH] test(forms): support directive composition with `FormField` Support composing the `FormField` directive with custom controls: --- packages/forms/signals/test/web/focus.spec.ts | 34 ++ .../forms/signals/test/web/form_field.spec.ts | 511 ++++++++++++++++++ 2 files changed, 545 insertions(+) diff --git a/packages/forms/signals/test/web/focus.spec.ts b/packages/forms/signals/test/web/focus.spec.ts index fb78b799b90..998b6b24558 100644 --- a/packages/forms/signals/test/web/focus.spec.ts +++ b/packages/forms/signals/test/web/focus.spec.ts @@ -73,6 +73,40 @@ describe('FieldState focus behavior', () => { expect(document.activeElement).toBe(customControl.querySelector('input')); }); + it('should delegate focus behavior to a custom control composed with a FormField', async () => { + let focusCalled = false; + + @Component({ + selector: 'custom-control', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + template: '', + }) + class CustomControl { + readonly value = model(); + readonly input = viewChild.required>('input'); + focus() { + focusCalled = true; + this.input().nativeElement.focus(); + } + } + + @Component({ + imports: [CustomControl], + template: ``, + }) + class TestCmp { + readonly f = form(signal('')); + } + + const fixture = await act(() => TestBed.createComponent(TestCmp)); + const customControl = fixture.nativeElement.firstChild as HTMLInputElement; + + await act(() => fixture.componentInstance.f().focusBoundControl()); + expect(focusCalled).toBeTrue(); + expect(document.activeElement).not.toBe(customControl); + expect(document.activeElement).toBe(customControl.querySelector('input')); + }); + it('should directly focus a custom control that has no custom focus logic', async () => { @Component({ selector: 'custom-control', diff --git a/packages/forms/signals/test/web/form_field.spec.ts b/packages/forms/signals/test/web/form_field.spec.ts index 7fbc9c1a4e2..59bb030e8e0 100644 --- a/packages/forms/signals/test/web/form_field.spec.ts +++ b/packages/forms/signals/test/web/form_field.spec.ts @@ -207,6 +207,33 @@ describe('field directive', () => { expect(comp.customControl().dirty()).toBe(true); }); + it('should bind to a custom control when composed as a host directive', () => { + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + readonly dirty = input.required(); + } + + @Component({ + template: ` `, + imports: [CustomControl], + }) + class TestCmp { + readonly data = signal(''); + readonly f = form(this.data); + readonly customControl = viewChild.required(CustomControl); + } + + const comp = act(() => TestBed.createComponent(TestCmp)).componentInstance; + expect(comp.customControl().dirty()).toBe(false); + act(() => comp.f().markAsDirty()); + expect(comp.customControl().dirty()).toBe(true); + }); + it('should bind to directive input on native control', () => { @Directive({selector: '[testDir]'}) class TestDir { @@ -385,6 +412,35 @@ describe('field directive', () => { expect(component.customControl().disabled()).toBe(true); }); + it('should bind to a custom control when composed as a host directive', () => { + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model(false); + readonly disabled = input(false); + } + + @Component({ + template: ` `, + imports: [CustomControl], + }) + class TestCmp { + readonly disabled = signal(false); + readonly f = form(signal(false), (p) => { + disabled(p, this.disabled); + }); + readonly customControl = viewChild.required(CustomControl); + } + + const comp = act(() => TestBed.createComponent(TestCmp)).componentInstance; + expect(comp.customControl().disabled()).toBe(false); + act(() => comp.disabled.set(true)); + expect(comp.customControl().disabled()).toBe(true); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -618,6 +674,37 @@ describe('field directive', () => { ]); }); + it('should bind to a custom control when composed as a host directive', () => { + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + readonly disabledReasons = + input.required[]>(); + } + + @Component({ + template: ` `, + imports: [CustomControl], + }) + class TestCmp { + readonly data = signal(''); + readonly f = form(this.data, (p) => { + disabled(p, () => 'Currently unavailable'); + }); + readonly customControl = viewChild.required(CustomControl); + } + + const comp = act(() => TestBed.createComponent(TestCmp)).componentInstance; + + expect(comp.customControl().disabledReasons()).toEqual([ + {message: 'Currently unavailable', fieldTree: comp.f}, + ]); + }); + it('should bind to custom control', () => { @Component({ selector: 'custom-control', @@ -788,6 +875,36 @@ describe('field directive', () => { expect(comp.customControl().errors()).toEqual([]); }); + it('should bind to a custom control when composed as a host directive', () => { + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + readonly errors = input.required[]>(); + } + + @Component({ + template: ` `, + imports: [CustomControl], + }) + class TestCmp { + readonly data = signal(''); + readonly f = form(this.data, (p) => { + required(p); + }); + readonly customControl = viewChild.required(CustomControl); + } + + const comp = act(() => TestBed.createComponent(TestCmp)).componentInstance; + expect(comp.customControl().errors()).toEqual([requiredError({fieldTree: comp.f})]); + + act(() => comp.f().value.set('valid')); + expect(comp.customControl().errors()).toEqual([]); + }); + it('should bind to custom control', () => { @Component({ selector: 'custom-control', @@ -949,6 +1066,39 @@ describe('field directive', () => { expect(component.customControl().hidden()).toBe(false); }); + it('should bind to a custom control when composed as a host directive', () => { + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + readonly hidden = input.required(); + } + + const visible = signal(false); + + @Component({ + imports: [CustomControl], + template: ``, + }) + class TestCmp { + readonly f = form(signal(''), (p) => { + hidden(p, () => !visible()); + }); + readonly field = signal(this.f); + readonly customControl = viewChild.required(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().hidden()).toBe(true); + + act(() => visible.set(true)); + expect(component.customControl().hidden()).toBe(false); + }); + it('should bind to a custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -1145,6 +1295,35 @@ describe('field directive', () => { expect(comp.customControl().invalid()).toBe(false); }); + it('should bind to a custom control when composed as a host directive', () => { + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + readonly invalid = input.required(); + } + + @Component({ + template: ` `, + imports: [CustomControl], + }) + class TestCmp { + readonly data = signal(''); + readonly f = form(this.data, (p) => { + required(p); + }); + readonly customControl = viewChild.required(CustomControl); + } + + const comp = act(() => TestBed.createComponent(TestCmp)).componentInstance; + expect(comp.customControl().invalid()).toBe(true); + act(() => comp.f().value.set('valid')); + expect(comp.customControl().invalid()).toBe(false); + }); + it('should bind to custom control', () => { @Component({ selector: 'custom-control', @@ -1324,6 +1503,38 @@ describe('field directive', () => { expect(fixture.nativeElement.innerText).toBe('ab'); }); + it('should bind to a custom control when composed as a host directive', () => { + @Component({ + selector: 'custom-control', + template: '{{ value() }}', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model(''); + readonly name = input(''); + } + + @Component({ + imports: [CustomControl], + template: ` + @for (item of f; track item) { + + } + `, + }) + class TestCmp { + readonly f = form(signal(['a', 'b']), {name: 'root'}); + readonly controls = viewChildren(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + const control0 = component.controls()[0]; + const control1 = component.controls()[1]; + expect(control0.name()).toBe('root.0'); + expect(control1.name()).toBe('root.1'); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: `{{ value() }}`}) class CustomControl implements FormValueControl { @@ -1473,6 +1684,52 @@ describe('field directive', () => { expect(comp.customControl().pending()).toBe(false); }); + it('should bind to a custom control when composed as a host directive', async () => { + const {promise, resolve} = promiseWithResolvers(); + + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model.required(); + readonly pending = input.required(); + } + + @Component({ + template: ` `, + imports: [CustomControl], + }) + class TestCmp { + readonly data = signal('test'); + readonly f = form(this.data, (p) => { + validateAsync(p, { + params: () => [], + factory: (params) => + resource({ + params, + loader: () => promise, + }), + onSuccess: (results) => results, + onError: () => null, + }); + }); + readonly customControl = viewChild.required(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const comp = fixture.componentInstance; + + expect(comp.customControl().pending()).toBe(true); + + resolve([]); + await promise; + await fixture.whenStable(); + + expect(comp.customControl().pending()).toBe(false); + }); + it('should be reset when field changes on custom control', async () => { const {promise, resolve} = promiseWithResolvers(); @@ -1656,6 +1913,37 @@ describe('field directive', () => { expect(component.child().readonly()).toBe(true); }); + it('should bind to a custom control when composed as a host directive', () => { + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model(''); + readonly readonly = input(false); + } + + @Component({ + imports: [CustomControl], + template: ``, + }) + class TestCmp { + readonly readonly = signal(false); + readonly f = form(signal(''), (p) => { + readonly(p, this.readonly); + }); + readonly customControl = viewChild.required(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().readonly()).toBe(false); + + act(() => component.readonly.set(true)); + expect(component.customControl().readonly()).toBe(true); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -1877,6 +2165,37 @@ describe('field directive', () => { expect(component.customControl().required()).toBe(true); }); + it('should bind to a custom control when composed as a host directive', () => { + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model(''); + readonly required = input(false); + } + + @Component({ + imports: [CustomControl], + template: ``, + }) + class TestCmp { + readonly required = signal(false); + readonly f = form(signal(''), (p) => { + required(p, {when: this.required}); + }); + readonly customControl = viewChild.required(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().required()).toBe(false); + + act(() => component.required.set(true)); + expect(component.customControl().required()).toBe(true); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -2098,6 +2417,37 @@ describe('field directive', () => { expect(component.customControl().max()).toBe(5); }); + it('should bind to a custom control when composed as a host directive', () => { + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model(0); + readonly max = input(); + } + + @Component({ + imports: [CustomControl], + template: ``, + }) + class TestCmp { + readonly max = signal(10); + readonly f = form(signal(5), (p) => { + max(p, this.max); + }); + readonly customControl = viewChild.required(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().max()).toBe(10); + + act(() => component.max.set(5)); + expect(component.customControl().max()).toBe(5); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -2319,6 +2669,37 @@ describe('field directive', () => { expect(component.customControl().min()).toBe(5); }); + it('should bind to a custom control when composed as a host directive', () => { + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model(0); + readonly min = input(); + } + + @Component({ + imports: [CustomControl], + template: ``, + }) + class TestCmp { + readonly min = signal(10); + readonly f = form(signal(15), (p) => { + min(p, this.min); + }); + readonly customControl = viewChild.required(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().min()).toBe(10); + + act(() => component.min.set(5)); + expect(component.customControl().min()).toBe(5); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -2540,6 +2921,37 @@ describe('field directive', () => { expect(component.customControl().maxLength()).toBe(5); }); + it('should bind to a custom control when composed as a host directive', () => { + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model(''); + readonly maxLength = input(); + } + + @Component({ + imports: [CustomControl], + template: ``, + }) + class TestCmp { + readonly maxLength = signal(10); + readonly f = form(signal(''), (p) => { + maxLength(p, this.maxLength); + }); + readonly customControl = viewChild.required(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().maxLength()).toBe(10); + + act(() => component.maxLength.set(5)); + expect(component.customControl().maxLength()).toBe(5); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -2777,6 +3189,37 @@ describe('field directive', () => { expect(component.customControl().minLength()).toBe(5); }); + it('should bind to a custom control when composed as a host directive', () => { + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model(''); + readonly minLength = input(); + } + + @Component({ + imports: [CustomControl], + template: ``, + }) + class TestCmp { + readonly minLength = signal(10); + readonly f = form(signal(''), (p) => { + minLength(p, this.minLength); + }); + readonly customControl = viewChild.required(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().minLength()).toBe(10); + + act(() => component.minLength.set(5)); + expect(component.customControl().minLength()).toBe(5); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -2994,6 +3437,37 @@ describe('field directive', () => { expect(component.customControl().pattern()).toEqual([/def/]); }); + it('should bind to a custom control when composed as a host directive', () => { + @Component({ + selector: 'custom-control', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomControl implements FormValueControl { + readonly value = model(''); + readonly pattern = input([]); + } + + @Component({ + imports: [CustomControl], + template: ``, + }) + class TestCmp { + readonly pattern = signal(/abc/); + readonly f = form(signal(''), (p) => { + pattern(p, this.pattern); + }); + readonly customControl = viewChild.required(CustomControl); + } + + const fixture = act(() => TestBed.createComponent(TestCmp)); + const component = fixture.componentInstance; + expect(component.customControl().pattern()).toEqual([/abc/]); + + act(() => component.pattern.set(/def/)); + expect(component.customControl().pattern()).toEqual([/def/]); + }); + it('should bind to custom control', () => { @Component({selector: 'custom-control', template: ``}) class CustomControl implements FormValueControl { @@ -3448,6 +3922,43 @@ describe('field directive', () => { expect(cmp.f().value()).toBe('typing'); }); + it('synchronizes with a custom value control when composed as a host directive', () => { + @Component({ + selector: 'my-input', + template: '', + hostDirectives: [{directive: FormField, inputs: ['formField']}], + }) + class CustomInput implements FormValueControl { + value = model(''); + } + + @Component({ + imports: [CustomInput], + template: ``, + }) + class TestCmp { + f = form(signal('test')); + } + + const fix = act(() => TestBed.createComponent(TestCmp)); + const input = fix.nativeElement.firstChild.firstChild as HTMLInputElement; + const cmp = fix.componentInstance as TestCmp; + + // Initial state + expect(input.value).toBe('test'); + + // Model -> View + act(() => cmp.f().value.set('testing')); + expect(input.value).toBe('testing'); + + // View -> Model + act(() => { + input.value = 'typing'; + input.dispatchEvent(new Event('input')); + }); + expect(cmp.f().value()).toBe('typing'); + }); + it('synchronizes with a custom value control directive', () => { @Directive({ selector: 'input[my-input]',