Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -297,6 +313,12 @@ export abstract class CheckboxBaseDirective implements AfterViewInit {
*/
protected _checked = false;

/**
* @hidden
* @internal
*/
protected _invalid = false;

/**
* @hidden
* @internal
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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;
});
}
}
Expand Down
17 changes: 16 additions & 1 deletion projects/igniteui-angular/radio/src/radio/radio.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -445,17 +464,21 @@ const dispatchCbEvent = (eventName, switchNativeElement, fixture) => {
};

@Component({
template: `<igx-switch #control [formField]="userForm.accepted">Accept</igx-switch>`,
template: `<igx-switch #control [formField]="userForm.accepted">Accept</igx-switch>
<igx-switch #newsletter [formField]="userForm.newsletter">Newsletter</igx-switch>`,
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 });
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading