From 9cb0fc3e5d551cbe987a69895ad80f71049819d7 Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Wed, 7 Oct 2026 12:48:46 +0300 Subject: [PATCH] fix(forms): gate invalid state on touched or dirty --- CHANGELOG.md | 7 +++++ .../src/checkbox/checkbox.component.spec.ts | 15 +++++++++++ .../src/checkbox/checkbox.component.ts | 7 ++++- .../checkbox/checkbox-base.directive.ts | 24 ++++++++++++++++- .../radio-group/radio-group.directive.spec.ts | 17 ++++++++++++ .../radio-group/radio-group.directive.ts | 12 ++++++++- .../radio/src/radio/radio.component.ts | 17 +++++++++++- .../src/switch/switch.component.spec.ts | 27 +++++++++++++++++-- .../switch/src/switch/switch.component.ts | 7 ++++- .../references/form-controls.md | 2 +- 10 files changed, 127 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0d8c9e3edf..2ef492d0f1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes for each version of this project will be documented in this file. +## 22.2.2 + +### Bug Fixes + +- **Forms** + - `igx-checkbox`, `igx-switch` and `igx-radio-group` no longer turn invalid while the bound Signal Forms field is untouched and pristine, e.g. right after `reset()` or when a cross-field rule makes a pristine field required. The invalid state now shows only once the field is touched or dirty, as with reactive and template-driven forms. + ## 22.2.1 ### New Features diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts index 0d55ee74a51..c84573527e9 100644 --- a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts +++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.spec.ts @@ -510,6 +510,21 @@ describe('IgxCheckboxComponent - Signal Forms', () => { fixture.detectChanges(); expect(instance.disabled).toBe(false); }); + + it('should return to the initial state when the form is reset', () => { + dispatchCbEvent('click', host, fixture); + expect(instance.checked).toBe(true); + expect(instance.invalid).toBe(false); + + fixture.componentInstance.model.set({ accepted: false }); + fixture.componentInstance.userForm().reset(); + fixture.detectChanges(); + + // The field itself is invalid again, but untouched and pristine + expect(fixture.componentInstance.userForm.accepted().invalid()).toBe(true); + expect(instance.invalid).toBe(false); + expect(host.classList.contains('igx-checkbox--invalid')).toBe(false); + }); }); @Component({ diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts index 074fd84d7b9..cda8d490ac3 100644 --- a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts +++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts @@ -145,7 +145,12 @@ export class IgxCheckboxComponent */ @HostBinding('class.igx-checkbox--invalid') @Input({ transform: booleanAttribute }) - public override invalid = false; + public override get invalid(): boolean { + return super.invalid; + } + public override set invalid(value: boolean) { + super.invalid = value; + } /** * Sets/gets whether the checkbox is readonly. diff --git a/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts b/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts index d0ea269bab1..72f7ce50bcd 100644 --- a/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/checkbox/checkbox-base.directive.ts @@ -64,7 +64,23 @@ export abstract class CheckboxBaseDirective implements AfterViewInit { public readonly!: boolean; public indeterminate!: boolean; public focused!: boolean; - public invalid!: boolean; + + public get invalid(): boolean { + return this._invalid; + } + + /** + * With a bound form control, invalid may show only once the control is touched + * or dirty. Signal Forms write the field's raw validity into this input, so the + * gate lives in the setter where that last write lands. + */ + public set invalid(value: boolean) { + if (this.control && (this.disabled || this.readonly || !this.control.touchedOrDirty)) { + value = false; + } + + this._invalid = value; + } @Input({ transform: booleanAttribute }) public get checked() { @@ -297,6 +313,12 @@ export abstract class CheckboxBaseDirective implements AfterViewInit { */ protected _checked = false; + /** + * @hidden + * @internal + */ + protected _invalid = false; + /** * @hidden * @internal diff --git a/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.spec.ts b/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.spec.ts index 5f196f6a494..33a457a4dd0 100644 --- a/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.spec.ts +++ b/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.spec.ts @@ -1259,6 +1259,23 @@ describe('IgxRadioGroupDirective - Signal Forms', () => { expect(domRadio.classList.contains('igx-radio--invalid')).toBe(false); })); + it('should return to the initial state when the form is reset', fakeAsync(() => { + radioGroup.radioButtons.first.select(); + fixture.detectChanges(); + tick(); + expect(radioGroup.invalid).toBe(false); + + fixture.componentInstance.model.set({ season: '' }); + fixture.componentInstance.userForm().reset(); + fixture.detectChanges(); + tick(); + + // The field itself is invalid again, but untouched and pristine + expect(fixture.componentInstance.userForm.season().invalid()).toBe(true); + expect(radioGroup.invalid).toBe(false); + expect(radioGroup.radioButtons.toArray().some(b => b.invalid)).toBe(false); + })); + it('should follow the disabled rule', fakeAsync(() => { fixture.componentInstance.isDisabled.set(true); fixture.detectChanges(); diff --git a/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.ts b/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.ts index 2ec886a99d1..18fc71f12b5 100644 --- a/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.ts +++ b/projects/igniteui-angular/radio/src/radio/radio-group/radio-group.directive.ts @@ -181,7 +181,16 @@ export class IgxRadioGroupDirective implements ControlValueAccessor, OnDestroy, return this._invalid; } + /** + * With a bound form control, invalid may show only once the control is touched + * or dirty. Signal Forms write the field's raw validity into this input, so the + * gate lives in the setter where that last write lands. + */ public set invalid(value: boolean) { + if (this.control && !this.control.touchedOrDirty) { + value = false; + } + this._invalid = value; this._setRadioButtonsInvalid(); } @@ -398,6 +407,7 @@ export class IgxRadioGroupDirective implements ControlValueAccessor, OnDestroy, * @internal */ private updateValidityOnBlur() { + // Runs first: the `invalid` setter suppresses the state below while untouched. this._onTouchedCallback(); this._radioButtons().forEach((button) => { @@ -697,7 +707,7 @@ export class IgxRadioGroupDirective implements ControlValueAccessor, OnDestroy, private _setRadioButtonsInvalid() { if (this._radioButtons) { this._radioButtons().forEach((button) => { - button.invalid = this._invalid; + button.groupInvalid = this._invalid; }); } } diff --git a/projects/igniteui-angular/radio/src/radio/radio.component.ts b/projects/igniteui-angular/radio/src/radio/radio.component.ts index ce0db37b67d..4bdbfabb816 100644 --- a/projects/igniteui-angular/radio/src/radio/radio.component.ts +++ b/projects/igniteui-angular/radio/src/radio/radio.component.ts @@ -116,6 +116,16 @@ export class IgxRadioComponent this._groupDisabled = value; } + /** + * Invalid state pushed down by the group, which styles its buttons as one. + * Set directly, so a button with its own pristine control still follows it. + * + * @hidden @internal + */ + public set groupInvalid(value: boolean) { + this._invalid = value; + } + /** * Sets/gets whether the radio button is invalid. * Default value is `false`. @@ -130,7 +140,12 @@ export class IgxRadioComponent */ @HostBinding('class.igx-radio--invalid') @Input({ transform: booleanAttribute }) - public override invalid = false; + public override get invalid(): boolean { + return super.invalid; + } + public override set invalid(value: boolean) { + super.invalid = value; + } /** * Sets/gets whether the radio component is on focus. diff --git a/projects/igniteui-angular/switch/src/switch/switch.component.spec.ts b/projects/igniteui-angular/switch/src/switch/switch.component.spec.ts index 2c7b5b4500f..299bf24f935 100644 --- a/projects/igniteui-angular/switch/src/switch/switch.component.spec.ts +++ b/projects/igniteui-angular/switch/src/switch/switch.component.spec.ts @@ -349,6 +349,25 @@ describe('IgxSwitchComponent - Signal Forms', () => { fixture.detectChanges(); expect(instance.disabled).toBe(false); }); + + it('should keep an untouched, pristine field initial when a cross-field rule turns it invalid', () => { + const newsletter = fixture.componentInstance.newsletter; + const newsletterHost = fixture.debugElement.queryAll(By.css('igx-switch'))[1].nativeElement; + expect(newsletter.invalid).toBe(false); + + // Accepting the terms makes the pristine newsletter field required, hence invalid + dispatchCbEvent('click', host, fixture); + expect(instance.checked).toBe(true); + expect(fixture.componentInstance.userForm.newsletter().invalid()).toBe(true); + + expect(newsletter.invalid).toBe(false); + expect(newsletterHost.classList.contains('igx-switch--invalid')).toBe(false); + + // Once touched, the invalid state shows + dispatchCbEvent('blur', newsletterHost, fixture); + expect(newsletter.invalid).toBe(true); + expect(newsletterHost.classList.contains('igx-switch--invalid')).toBe(true); + }); }); @Component({ @@ -445,17 +464,21 @@ const dispatchCbEvent = (eventName, switchNativeElement, fixture) => { }; @Component({ - template: `Accept`, + template: `Accept + Newsletter`, changeDetection: ChangeDetectionStrategy.Eager, imports: [IgxSwitchComponent, FormField] }) class SwitchSignalFormComponent { @ViewChild('control', { static: true }) public control: IgxSwitchComponent; + @ViewChild('newsletter', { static: true }) public newsletter: IgxSwitchComponent; - public model = signal({ accepted: false }); + public model = signal({ accepted: false, newsletter: false }); public isDisabled = signal(false); public userForm = signalForm(this.model, (path) => { required(path.accepted); disabled(path.accepted, { when: () => this.isDisabled() }); + // Cross-field rule: subscribing becomes required once the terms are accepted + required(path.newsletter, { when: () => this.model().accepted }); }); } diff --git a/projects/igniteui-angular/switch/src/switch/switch.component.ts b/projects/igniteui-angular/switch/src/switch/switch.component.ts index 4da1a1640f5..d78873e27de 100644 --- a/projects/igniteui-angular/switch/src/switch/switch.component.ts +++ b/projects/igniteui-angular/switch/src/switch/switch.component.ts @@ -103,7 +103,12 @@ export class IgxSwitchComponent */ @HostBinding('class.igx-switch--invalid') @Input({ transform: booleanAttribute }) - public override invalid = false; + public override get invalid(): boolean { + return super.invalid; + } + public override set invalid(value: boolean) { + super.invalid = value; + } /** * Sets/gets whether the switch component is on focus. diff --git a/skills/igniteui-angular-components/references/form-controls.md b/skills/igniteui-angular-components/references/form-controls.md index c1a48b06579..42a2f56e7d2 100644 --- a/skills/igniteui-angular-components/references/form-controls.md +++ b/skills/igniteui-angular-components/references/form-controls.md @@ -345,7 +345,7 @@ export class SignupComponent { } ``` -`submit()` marks every field touched, so invalid controls show their error state the same way a reactive `markAllAsTouched()` does. +`submit()` marks every field touched, so invalid controls show their error state the same way a reactive `markAllAsTouched()` does. As with reactive forms, the invalid style shows only once a field is touched or dirty; an untouched, pristine field stays initial even while the field reports invalid (e.g. right after `reset()`). ## Key Rules