test(forms): support directive composition with FormField

Support composing the `FormField` directive with custom controls:
This commit is contained in:
Leon Senft
2026-03-25 13:35:02 -07:00
committed by GitHub
parent 0007723a03
commit b5af15a332
2 changed files with 545 additions and 0 deletions
@@ -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: '<input #input />',
})
class CustomControl {
readonly value = model<string>();
readonly input = viewChild.required<ElementRef<HTMLInputElement>>('input');
focus() {
focusCalled = true;
this.input().nativeElement.focus();
}
}
@Component({
imports: [CustomControl],
template: `<custom-control [formField]="f" />`,
})
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',
@@ -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<string> {
readonly value = model.required<string>();
readonly dirty = input.required<boolean>();
}
@Component({
template: ` <custom-control [formField]="f" /> `,
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<boolean> {
readonly value = model(false);
readonly disabled = input(false);
}
@Component({
template: ` <custom-control [formField]="f" /> `,
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<boolean> {
@@ -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<string> {
readonly value = model.required<string>();
readonly disabledReasons =
input.required<readonly WithOptionalFieldTree<DisabledReason>[]>();
}
@Component({
template: ` <custom-control [formField]="f" /> `,
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<string> {
readonly value = model.required<string>();
readonly errors = input.required<readonly WithOptionalFieldTree<ValidationError>[]>();
}
@Component({
template: ` <custom-control [formField]="f" /> `,
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<string> {
readonly value = model.required<string>();
readonly hidden = input.required<boolean>();
}
const visible = signal(false);
@Component({
imports: [CustomControl],
template: `<custom-control [formField]="field()" />`,
})
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<string> {
@@ -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<string> {
readonly value = model.required<string>();
readonly invalid = input.required<boolean>();
}
@Component({
template: ` <custom-control [formField]="f" /> `,
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<string> {
readonly value = model('');
readonly name = input('');
}
@Component({
imports: [CustomControl],
template: `
@for (item of f; track item) {
<custom-control [formField]="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<string> {
@@ -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<ValidationError[]>();
@Component({
selector: 'custom-control',
template: '',
hostDirectives: [{directive: FormField, inputs: ['formField']}],
})
class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
readonly pending = input.required<boolean>();
}
@Component({
template: ` <custom-control [formField]="f" /> `,
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<ValidationError[]>();
@@ -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<string> {
readonly value = model('');
readonly readonly = input(false);
}
@Component({
imports: [CustomControl],
template: `<custom-control [formField]="f" />`,
})
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<string> {
@@ -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<string> {
readonly value = model('');
readonly required = input(false);
}
@Component({
imports: [CustomControl],
template: `<custom-control [formField]="f" />`,
})
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<string> {
@@ -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<number> {
readonly value = model(0);
readonly max = input<number>();
}
@Component({
imports: [CustomControl],
template: `<custom-control [formField]="f" />`,
})
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<number> {
@@ -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<number> {
readonly value = model(0);
readonly min = input<number>();
}
@Component({
imports: [CustomControl],
template: `<custom-control [formField]="f" />`,
})
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<number> {
@@ -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<string> {
readonly value = model('');
readonly maxLength = input<number>();
}
@Component({
imports: [CustomControl],
template: `<custom-control [formField]="f" />`,
})
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<string> {
@@ -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<string> {
readonly value = model('');
readonly minLength = input<number>();
}
@Component({
imports: [CustomControl],
template: `<custom-control [formField]="f" />`,
})
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<string> {
@@ -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<string> {
readonly value = model('');
readonly pattern = input<readonly RegExp[]>([]);
}
@Component({
imports: [CustomControl],
template: `<custom-control [formField]="f" />`,
})
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<string> {
@@ -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: '<input #i [value]="value()" (input)="value.set(i.value)" />',
hostDirectives: [{directive: FormField, inputs: ['formField']}],
})
class CustomInput implements FormValueControl<string> {
value = model('');
}
@Component({
imports: [CustomInput],
template: `<my-input [formField]="f" />`,
})
class TestCmp {
f = form<string>(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]',