diff --git a/CHANGELOG.md b/CHANGELOG.md index 8be5eee9c4a..ffc39efcc58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ All notable changes for each version of this project will be documented in this - Added the `$header-background` parameter to the tabs theme. It paints the whole tabs header, behind the tab items, and follows `$item-background` unless set. Thus a translucent `$item-background` composites over the header color instead of stacking on a copy of itself, and the header can be made transparent on its own. Both the header and the items are painted in all themes now; before, the header was painted in the Material and Bootstrap themes only. - The header keeps its height when there are no tabs, 48px in the Material and Bootstrap themes, 44px in Fluent and 40px in Indigo, as in Web Components. Before, it collapsed and lost its styles, since they were attached to the tab item component. +### 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