diff --git a/packages/core/src/render3/instructions/control.ts b/packages/core/src/render3/instructions/control.ts index 29993d2c46b..4bdf764397d 100644 --- a/packages/core/src/render3/instructions/control.ts +++ b/packages/core/src/render3/instructions/control.ts @@ -930,7 +930,9 @@ type ControlBindings = { const CONTROL_BINDING_NAMES = { disabled: 'disabled', disabledReasons: 'disabledReasons', + dirty: 'dirty', errors: 'errors', + hidden: 'hidden', invalid: 'invalid', max: 'max', maxLength: 'maxLength', @@ -938,6 +940,7 @@ const CONTROL_BINDING_NAMES = { minLength: 'minLength', name: 'name', pattern: 'pattern', + pending: 'pending', readonly: 'readonly', required: 'required', touched: 'touched', diff --git a/packages/core/src/render3/interfaces/control.ts b/packages/core/src/render3/interfaces/control.ts index e5afd496557..3f94682b446 100644 --- a/packages/core/src/render3/interfaces/control.ts +++ b/packages/core/src/render3/interfaces/control.ts @@ -143,6 +143,21 @@ export interface ɵFieldState { */ readonly touched: Signal; + /** + * A signal indicating whether the field value has been changed by user. + */ + readonly dirty: Signal; + + /** + * A signal indicating whether the field is hidden. + */ + readonly hidden: Signal; + + /** + * A signal indicating whether there are any validators still pending for this field. + */ + readonly pending: Signal; + /** * A writable signal containing the value for this field. * diff --git a/packages/forms/signals/test/web/field_directive.spec.ts b/packages/forms/signals/test/web/field_directive.spec.ts index 9da08567ddd..e57b1e04bd4 100644 --- a/packages/forms/signals/test/web/field_directive.spec.ts +++ b/packages/forms/signals/test/web/field_directive.spec.ts @@ -18,6 +18,7 @@ import { inputBinding, model, numberAttribute, + resource, signal, viewChild, viewChildren, @@ -39,6 +40,7 @@ import { provideSignalFormsConfig, readonly, required, + validateAsync, type DisabledReason, type FieldTree, type FormCheckboxControl, @@ -1886,6 +1888,109 @@ describe('field directive', () => { expect(comp.myInput().invalid()).toBe(false); }); + it('should synchronize hidden status', () => { + @Component({ + selector: 'my-input', + template: '', + }) + class CustomInput implements FormValueControl { + value = model(''); + hidden = input(false); + } + + @Component({ + template: ` + + `, + imports: [CustomInput, Field], + }) + class HiddenTestCmp { + myInput = viewChild.required(CustomInput); + data = signal(''); + f = form(this.data, (p) => { + hidden(p, ({value}) => value() === ''); + }); + } + + const comp = act(() => TestBed.createComponent(HiddenTestCmp)).componentInstance; + expect(comp.myInput().hidden()).toBe(true); + act(() => comp.f().value.set('visible')); + expect(comp.myInput().hidden()).toBe(false); + }); + + it('should synchronize dirty status', () => { + @Component({ + selector: 'my-input', + template: '', + }) + class CustomInput implements FormValueControl { + value = model(''); + dirty = input(false); + } + + @Component({ + template: ` + + `, + imports: [CustomInput, Field], + }) + class DirtyTestCmp { + myInput = viewChild.required(CustomInput); + data = signal(''); + f = form(this.data); + } + + const comp = act(() => TestBed.createComponent(DirtyTestCmp)).componentInstance; + expect(comp.myInput().dirty()).toBe(false); + act(() => comp.f().markAsDirty()); + expect(comp.myInput().dirty()).toBe(true); + }); + + it('should synchronize pending status', async () => { + const {promise, resolve} = promiseWithResolvers(); + + @Component({ + selector: 'my-input', + template: '', + }) + class CustomInput implements FormValueControl { + value = model(''); + pending = input(false); + } + + @Component({ + template: ` + + `, + imports: [CustomInput, Field], + }) + class PendingTestCmp { + myInput = viewChild.required(CustomInput); + data = signal('test'); + f = form(this.data, (p) => { + validateAsync(p, { + params: () => [], + factory: (params) => + resource({ + params, + loader: () => promise, + }), + onSuccess: (results) => results, + onError: () => null, + }); + }); + } + + const fix = act(() => TestBed.createComponent(PendingTestCmp)); + + expect(fix.componentInstance.myInput().pending()).toBe(true); + + resolve([]); + await promise; + await fix.whenStable(); + expect(fix.componentInstance.myInput().pending()).toBe(false); + }); + it(`should mark field as touched on native control 'blur' event`, () => { @Component({ imports: [Field],