refactor(forms): bind field properties to all directives on custom controls

The framework will now bind the field state properties to all matching
directive inputs on custom form controls.

(cherry picked from commit 0c8f15d546)
This commit is contained in:
Leon Senft 2025-12-23 15:28:25 -08:00 committed by kirjs
parent a4ba820d36
commit ff37a13913
2 changed files with 534 additions and 35 deletions

View file

@ -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<unknown>,
directive: unknown,
bindings: ControlBindings,
state: ɵFieldState<unknown>,
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);
}
}
}
}

View file

@ -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<boolean>();
}
@Component({
selector: 'custom-control',
template: '',
})
class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<custom-control [field]="f" testDir />`,
})
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<string> {
@ -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<boolean>();
}
@Component({selector: 'input[custom]', template: ``})
class CustomControl implements FormValueControl<boolean> {
readonly value = model(false);
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<input custom [field]="f" testDir />`,
})
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<readonly WithOptionalField<DisabledReason>[]>();
}
@Component({
selector: 'custom-control',
template: '',
})
class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<custom-control [field]="f" testDir />`,
})
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<string> {
@ -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<readonly WithOptionalField<ValidationError>[]>();
}
@Component({
selector: 'custom-control',
template: '',
})
class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<custom-control [field]="f" testDir />`,
})
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<string> {
@ -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<boolean>();
}
@Component({selector: 'custom-control', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<custom-control [field]="f" testDir />`,
})
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<string> {
@ -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<boolean>();
}
@Component({selector: 'custom-control', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<custom-control [field]="f" testDir />`,
})
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<string> {
@ -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<string>();
}
@Component({selector: 'input[custom]', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<input custom [field]="f" testDir />`,
})
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<ValidationError[]>();
@Directive({selector: '[testDir]'})
class TestDir {
readonly pending = input.required<boolean>();
}
@Component({selector: 'custom-control', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model.required<string>();
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<custom-control [field]="f" testDir />`,
})
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<boolean>();
}
@Component({selector: 'input[custom]', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model('');
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<input custom [field]="f" testDir />`,
})
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<boolean>();
}
@Component({selector: 'input[custom]', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model('');
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<input custom [field]="f" testDir />`,
})
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<number | undefined>();
}
@Component({selector: 'input[custom]', template: ``})
class CustomControl implements FormValueControl<number> {
readonly value = model(0);
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<input custom type="number" [field]="f" testDir />`,
})
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<number | undefined>();
}
@Component({selector: 'input[custom]', template: ``})
class CustomControl implements FormValueControl<number> {
readonly value = model(0);
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<input custom type="number" [field]="f" testDir />`,
})
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<number | undefined>();
}
@Component({selector: 'input[custom]', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model('');
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<input custom [field]="f" testDir />`,
})
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<number | undefined>();
}
@Component({selector: 'input[custom]', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model('');
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<input custom [field]="f" testDir />`,
})
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<readonly RegExp[]>();
}
@Component({selector: 'custom-control', template: ``})
class CustomControl implements FormValueControl<string> {
readonly value = model('');
}
@Component({
imports: [Field, TestDir, CustomControl],
template: `<custom-control [field]="f" testDir />`,
})
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*/]);
});
});
});