From 24d6df628bd94651f684dfb2ad5122c796cfda8c Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Mon, 14 Sep 2026 13:28:36 +0300 Subject: [PATCH 1/7] refactor(directives): back CheckboxBaseDirective state with signals Replace the plain backing fields of the shared checkbox/switch/radio base directive with Angular signals, grouped at the top of the class so that each JSDoc block documents the public accessor rather than the backing field. The public API is unchanged: every member keeps its @Input()/@Output() decorator and plain property shape, so `checkbox.checked = true` and [checked]="x" behave exactly as before. Also: - replace the destroy$/takeUntil cleanup around ngControl.statusChanges with takeUntilDestroyed(); destroy$ was never completed, so it never fired - move the @HostBinding/@HostListener declarations into the decorator's host metadata - expose destroyRef so a parent can scope subscriptions to a single instance Co-Authored-By: Claude Opus 5 (1M context) --- .../checkbox/checkbox-base.directive.ts | 234 ++++++++++++------ 1 file changed, 163 insertions(+), 71 deletions(-) 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 07c1aa393e9..599e2e460b7 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 @@ -1,8 +1,8 @@ -import { Directive, EventEmitter, HostListener, HostBinding, Input, Output, ViewChild, ElementRef, ChangeDetectorRef, booleanAttribute, inject, AfterViewInit } from '@angular/core'; +import { Directive, EventEmitter, Input, Output, ViewChild, ElementRef, ChangeDetectorRef, booleanAttribute, inject, AfterViewInit, signal, DestroyRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NgControl, Validators } from '@angular/forms'; import { IBaseEventArgs } from 'igniteui-angular/core'; -import { noop, Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { noop } from 'rxjs'; export const LabelPosition = { BEFORE: 'before', @@ -17,11 +17,46 @@ export interface IChangeCheckboxEventArgs extends IBaseEventArgs { let nextId = 0; -@Directive() +@Directive({ + host: { + '[attr.id]': '_id()', + '(keyup)': 'onKeyUp($event)', + '(click)': '_onCheckboxClick($event)', + '(blur)': 'onBlur()', + } +}) export class CheckboxBaseDirective implements AfterViewInit { protected cdr = inject(ChangeDetectorRef); + + /** + * @hidden + * @internal + */ + public destroyRef = inject(DestroyRef); + public ngControl = inject(NgControl, { optional: true, self: true }); + // Internal state. + // `_labelId` and `_ariaLabelledBy` snapshot `id`/`labelId` once on + // initialization - deliberately not derived, so that a later write to `id` + // never clobbers a caller-provided `labelId`. + protected readonly _id = signal(`igx-checkbox-${nextId++}`); + protected readonly _labelId = signal(`${this.id}-label`); + protected readonly _ariaLabelledBy = signal(this.labelId); + protected readonly _ariaLabel = signal(null); + protected readonly _checked = signal(false); + protected readonly _required = signal(false); + protected readonly _disabled = signal(false); + protected readonly _readonly = signal(false); + protected readonly _indeterminate = signal(false); + protected readonly _focused = signal(false); + protected readonly _invalid = signal(false); + protected readonly _value = signal(undefined); + protected readonly _name = signal(undefined!); + protected readonly _tabindex = signal(null!); + protected readonly _labelPosition = signal(LabelPosition.AFTER); + protected readonly _disableRipple = signal(false); + /** * An event that is emitted after the checkbox state is changed. * Provides references to the checkbox and the `checked` property as event arguments. @@ -30,12 +65,6 @@ export class CheckboxBaseDirective implements AfterViewInit { @Output() public readonly change: EventEmitter = new EventEmitter(); - /** - * @hidden - * @internal - */ - public destroy$ = new Subject(); - /** * Returns reference to the native checkbox element. * @@ -59,21 +88,51 @@ export class CheckboxBaseDirective implements AfterViewInit { public nativeLabel!: ElementRef; public cssClass!: string; - public disabled!: boolean; - public readonly!: boolean; - public indeterminate!: boolean; - public focused!: boolean; - public invalid!: boolean; + + public get disabled(): boolean { + return this._disabled(); + } + public set disabled(value: boolean) { + this._disabled.set(value); + } + + public get readonly(): boolean { + return this._readonly(); + } + public set readonly(value: boolean) { + this._readonly.set(value); + } + + public get indeterminate(): boolean { + return this._indeterminate(); + } + public set indeterminate(value: boolean) { + this._indeterminate.set(value); + } + + public get focused(): boolean { + return this._focused(); + } + public set focused(value: boolean) { + this._focused.set(value); + } + + public get invalid(): boolean { + return this._invalid(); + } + public set invalid(value: boolean) { + this._invalid.set(value); + } @Input({ transform: booleanAttribute }) public get checked() { - return this._checked; + return this._checked(); } public set checked(value: boolean) { - if (this._checked !== value) { - this._checked = value; - this._onChangeCallback(this._checked); + if (this._checked() !== value) { + this._checked.set(value); + this._onChangeCallback(value); } } @@ -112,9 +171,13 @@ export class CheckboxBaseDirective implements AfterViewInit { * let checkboxId = this.checkbox.id; * ``` */ - @HostBinding('attr.id') @Input() - public id = `igx-checkbox-${nextId++}`; + public get id() { + return this._id(); + } + public set id(value: string) { + this._id.set(value); + } /** * Sets/gets the id of the `label` element. @@ -128,7 +191,13 @@ export class CheckboxBaseDirective implements AfterViewInit { * let labelId = this.component.labelId; * ``` */ - @Input() public labelId = `${this.id}-label`; + @Input() + public get labelId() { + return this._labelId(); + } + public set labelId(value: string) { + this._labelId.set(value); + } /** * Sets/gets the `value` attribute. @@ -141,7 +210,13 @@ export class CheckboxBaseDirective implements AfterViewInit { * let value = this.checkbox.value; * ``` */ - @Input() public value: any; + @Input() + public get value() { + return this._value(); + } + public set value(value: any) { + this._value.set(value); + } /** * Sets/gets the `name` attribute. @@ -154,7 +229,13 @@ export class CheckboxBaseDirective implements AfterViewInit { * let name = this.checkbox.name; * ``` */ - @Input() public name!: string; + @Input() + public get name() { + return this._name(); + } + public set name(value: string) { + this._name.set(value); + } /** * Sets/gets the value of the `tabindex` attribute. @@ -167,7 +248,13 @@ export class CheckboxBaseDirective implements AfterViewInit { * let tabIndex = this.checkbox.tabindex; * ``` */ - @Input() public tabindex: number = null!; + @Input() + public get tabindex() { + return this._tabindex(); + } + public set tabindex(value: number) { + this._tabindex.set(value); + } /** * Sets/gets the position of the `label`. @@ -182,7 +269,12 @@ export class CheckboxBaseDirective implements AfterViewInit { * ``` */ @Input() - public labelPosition: LabelPosition | string = LabelPosition.AFTER; + public get labelPosition() { + return this._labelPosition(); + } + public set labelPosition(value: LabelPosition | string) { + this._labelPosition.set(value); + } /** * Enables/Disables the ripple effect. @@ -197,7 +289,12 @@ export class CheckboxBaseDirective implements AfterViewInit { * ``` */ @Input({ transform: booleanAttribute }) - public disableRipple = false; + public get disableRipple() { + return this._disableRipple(); + } + public set disableRipple(value: boolean) { + this._disableRipple.set(value); + } /** * Sets/gets the `aria-labelledby` attribute. @@ -212,7 +309,12 @@ export class CheckboxBaseDirective implements AfterViewInit { * ``` */ @Input('aria-labelledby') - public ariaLabelledBy = this.labelId; + public get ariaLabelledBy() { + return this._ariaLabelledBy(); + } + public set ariaLabelledBy(value: string) { + this._ariaLabelledBy.set(value); + } /** * Sets/gets the value of the `aria-label` attribute. @@ -226,7 +328,12 @@ export class CheckboxBaseDirective implements AfterViewInit { * ``` */ @Input('aria-label') - public ariaLabel: string | null = null; + public get ariaLabel() { + return this._ariaLabel(); + } + public set ariaLabel(value: string | null) { + this._ariaLabel.set(value); + } constructor() { if (this.ngControl !== null) { @@ -248,13 +355,13 @@ export class CheckboxBaseDirective implements AfterViewInit { */ @Input({ transform: booleanAttribute }) public get required(): boolean { - return this._required || this.nativeElement.hasAttribute('required'); + return this._required() || this.nativeElement.hasAttribute('required'); } public set required(value: boolean) { if (!value) { this.nativeElement.removeAttribute('required'); } - this._required = value; + this._required.set(value); } /** @@ -264,16 +371,16 @@ export class CheckboxBaseDirective implements AfterViewInit { public ngAfterViewInit() { if (this.ngControl) { this.ngControl.statusChanges! - .pipe(takeUntil(this.destroy$)) + .pipe(takeUntilDestroyed(this.destroyRef)) .subscribe(this.updateValidityState.bind(this)); if ( this.ngControl.control!.validator || this.ngControl.control!.asyncValidator ) { - this._required = this.ngControl.control!.hasValidator( + this._required.set(this.ngControl.control!.hasValidator( Validators.required - ); + )); this.cdr.detectChanges(); } } @@ -295,27 +402,13 @@ export class CheckboxBaseDirective implements AfterViewInit { */ private _onTouchedCallback: () => void = noop; - /** - * @hidden - * @internal - */ - protected _checked = false; - - /** - * @hidden - * @internal - */ - public _required = false; - /** @hidden @internal */ - @HostListener('keyup', ['$event']) public onKeyUp(event: KeyboardEvent) { event.stopPropagation(); - this.focused = true; + this._focused.set(true); } /** @hidden @internal */ - @HostListener('click', ['$event']) public _onCheckboxClick(event: PointerEvent | MouseEvent) { // Since the original checkbox is hidden and the label // is used for styling and to change the checked state of the checkbox, @@ -323,7 +416,7 @@ export class CheckboxBaseDirective implements AfterViewInit { // as it gets triggered on label click // NOTE: The above is no longer valid, as the native checkbox is not labeled // by the SVG anymore. - if (this.disabled || this.readonly) { + if (this._disabled() || this._readonly()) { // readonly prevents the component from changing state (see toggle() method). // However, the native checkbox can still be activated through user interaction (focus + space, label click) // Prevent the native change so the input remains in sync @@ -333,16 +426,16 @@ export class CheckboxBaseDirective implements AfterViewInit { this.nativeElement.focus(); - this.indeterminate = false; - this.checked = !this.checked; + this._indeterminate.set(false); + this.checked = !this._checked(); this.updateValidityState(); // K.D. March 23, 2021 Emitting on click and not on the setter because otherwise every component // bound on change would have to perform self checks for weather the value has changed because // of the initial set on initialization this.change.emit({ - checked: this.checked, - value: this.value, + checked: this._checked(), + value: this._value(), owner: this, }); } @@ -352,10 +445,10 @@ export class CheckboxBaseDirective implements AfterViewInit { * @internal */ public get ariaChecked() { - if (this.indeterminate) { + if (this._indeterminate()) { return 'mixed'; } else { - return this.checked; + return this._checked(); } } @@ -367,21 +460,20 @@ export class CheckboxBaseDirective implements AfterViewInit { } /** @hidden @internal */ - @HostListener('blur') public onBlur() { - this.focused = false; + this._focused.set(false); this._onTouchedCallback(); this.updateValidityState(); } /** @hidden @internal */ public writeValue(value: boolean) { - this._checked = value; + this._checked.set(value); } /** @hidden @internal */ public get labelClass(): string { - switch (this.labelPosition) { + switch (this._labelPosition()) { case LabelPosition.BEFORE: return `${this.cssClass}__label ${this.cssClass}__label--before`; case LabelPosition.AFTER: @@ -402,7 +494,7 @@ export class CheckboxBaseDirective implements AfterViewInit { /** @hidden @internal */ public setDisabledState(isDisabled: boolean) { - this.disabled = isDisabled; + this._disabled.set(isDisabled); } /** @hidden @internal */ @@ -417,16 +509,16 @@ export class CheckboxBaseDirective implements AfterViewInit { protected updateValidityState() { if (this.ngControl) { if ( - !this.disabled && - !this.readonly && + !this._disabled() && + !this._readonly() && (this.ngControl.control!.touched || this.ngControl.control!.dirty) ) { // the control is not disabled and is touched or dirty - this.invalid = this.ngControl.invalid!; + this._invalid.set(this.ngControl.invalid!); } else { // if the control is untouched, pristine, or disabled, its state is initial. This is when the user did not interact // with the checkbox or when the form/control is reset - this.invalid = false; + this._invalid.set(false); } } else { this.checkNativeValidity(); @@ -442,14 +534,14 @@ export class CheckboxBaseDirective implements AfterViewInit { */ private checkNativeValidity() { if ( - !this.disabled && - this._required && - !this.checked && - !this.readonly + !this._disabled() && + this._required() && + !this._checked() && + !this._readonly() ) { - this.invalid = true; + this._invalid.set(true); } else { - this.invalid = false; + this._invalid.set(false); } } } From 42c5a4a58008bfbbfa916174f79ee13c8404da3a Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Mon, 14 Sep 2026 13:28:45 +0300 Subject: [PATCH 2/7] refactor(checkbox): migrate IgxCheckboxComponent to OnPush Switch from ChangeDetectionStrategy.Eager to OnPush, now that the state the component renders is signal-backed and marks the view dirty on every write. Also moves the @HostBinding declarations into the decorator's host metadata. The public API is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/checkbox/checkbox.component.ts | 76 ++++++++++++++----- 1 file changed, 55 insertions(+), 21 deletions(-) diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts index 074fd84d7b9..ef9e9a75113 100644 --- a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts +++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.ts @@ -1,11 +1,11 @@ import { - Component, - HostBinding, - Input, - AfterViewInit, - booleanAttribute, - ChangeDetectionStrategy, - ViewEncapsulation + Component, + Input, + AfterViewInit, + booleanAttribute, + ChangeDetectionStrategy, + ViewEncapsulation, + signal } from '@angular/core'; import { CheckboxBaseDirective, IgxRippleDirective } from 'igniteui-angular/directives'; import { ControlValueAccessor } from '@angular/forms'; @@ -46,12 +46,23 @@ import { EditorProvider, EDITOR_PROVIDER } from 'igniteui-angular/core'; templateUrl: 'checkbox.component.html', styleUrl: 'checkbox.component.css', encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.OnPush, imports: [IgxRippleDirective], + host: { + '[class.igx-checkbox]': 'cssClass', + '[class.igx-checkbox--focused]': 'focused', + '[class.igx-checkbox--indeterminate]': 'indeterminate', + '[class.igx-checkbox--checked]': 'checked', + '[class.igx-checkbox--disabled]': 'disabled', + '[class.igx-checkbox--invalid]': 'invalid', + '[class.igx-checkbox--plain]': '_disableTransitions()', + }, }) export class IgxCheckboxComponent extends CheckboxBaseDirective implements AfterViewInit, ControlValueAccessor, EditorProvider { + protected readonly _disableTransitions = signal(false); + /** * Returns the class of the checkbox component. * @@ -60,7 +71,6 @@ export class IgxCheckboxComponent * let class = this.checkbox.cssClass; * ``` */ - @HostBinding('class.igx-checkbox') public override cssClass = 'igx-checkbox'; /** @@ -75,8 +85,12 @@ export class IgxCheckboxComponent * let isFocused = this.checkbox.focused; * ``` */ - @HostBinding('class.igx-checkbox--focused') - public override focused = false; + public override get focused() { + return super.focused; + } + public override set focused(value: boolean) { + super.focused = value; + } /** * Sets/gets the checkbox indeterminate visual state. @@ -90,9 +104,13 @@ export class IgxCheckboxComponent * let isIndeterminate = this.checkbox.indeterminate; * ``` */ - @HostBinding('class.igx-checkbox--indeterminate') @Input({ transform: booleanAttribute }) - public override indeterminate = false; + public override get indeterminate() { + return super.indeterminate; + } + public override set indeterminate(value: boolean) { + super.indeterminate = value; + } /** * Sets/gets whether the checkbox is checked. @@ -106,7 +124,6 @@ export class IgxCheckboxComponent * let isChecked = this.checkbox.checked; * ``` */ - @HostBinding('class.igx-checkbox--checked') @Input({ transform: booleanAttribute }) public override set checked(value: boolean) { super.checked = value; @@ -127,9 +144,13 @@ export class IgxCheckboxComponent * let isDisabled = this.checkbox.disabled; * ``` */ - @HostBinding('class.igx-checkbox--disabled') @Input({ transform: booleanAttribute }) - public override disabled = false; + public override get disabled() { + return super.disabled; + } + public override set disabled(value: boolean) { + super.disabled = value; + } /** * Sets/gets whether the checkbox is invalid. @@ -143,9 +164,13 @@ export class IgxCheckboxComponent * let isInvalid = this.checkbox.invalid; * ``` */ - @HostBinding('class.igx-checkbox--invalid') @Input({ transform: booleanAttribute }) - public override invalid = false; + public override get invalid() { + return super.invalid; + } + public override set invalid(value: boolean) { + super.invalid = value; + } /** * Sets/gets whether the checkbox is readonly. @@ -160,7 +185,12 @@ export class IgxCheckboxComponent * ``` */ @Input({ transform: booleanAttribute }) - public override readonly = false; + public override get readonly() { + return super.readonly; + } + public override set readonly(value: boolean) { + super.readonly = value; + } /** * Sets/gets whether the checkbox should disable all css transitions. @@ -174,7 +204,11 @@ export class IgxCheckboxComponent * let disableTransitions = this.checkbox.disableTransitions; * ``` */ - @HostBinding('class.igx-checkbox--plain') @Input({ transform: booleanAttribute }) - public disableTransitions = false; + public get disableTransitions() { + return this._disableTransitions(); + } + public set disableTransitions(value: boolean) { + this._disableTransitions.set(value); + } } From d03a4e419f4e4519a32ea6041e8dc18103ad057e Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Mon, 14 Sep 2026 13:28:45 +0300 Subject: [PATCH 3/7] refactor(switch): migrate IgxSwitchComponent to OnPush Switch from ChangeDetectionStrategy.Eager to OnPush, now that the state the component renders is signal-backed and marks the view dirty on every write. Also moves the @HostBinding declarations into the decorator's host metadata. The public API is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../switch/src/switch/switch.component.ts | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/projects/igniteui-angular/switch/src/switch/switch.component.ts b/projects/igniteui-angular/switch/src/switch/switch.component.ts index 4da1a1640f5..021a4595d7f 100644 --- a/projects/igniteui-angular/switch/src/switch/switch.component.ts +++ b/projects/igniteui-angular/switch/src/switch/switch.component.ts @@ -1,6 +1,5 @@ import { Component, - HostBinding, Input, AfterViewInit, booleanAttribute, @@ -43,8 +42,15 @@ import { EditorProvider, EDITOR_PROVIDER } from 'igniteui-angular/core'; templateUrl: 'switch.component.html', styleUrl: 'switch.component.css', encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.Eager, - imports: [IgxRippleDirective] + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IgxRippleDirective], + host: { + '[class.igx-switch]': 'cssClass', + '[class.igx-switch--checked]': 'checked', + '[class.igx-switch--disabled]': 'disabled', + '[class.igx-switch--invalid]': 'invalid', + '[class.igx-switch--focused]': 'focused', + } }) export class IgxSwitchComponent extends CheckboxBaseDirective @@ -57,7 +63,6 @@ export class IgxSwitchComponent * let switchClass = this.switch.cssClass; * ``` */ - @HostBinding('class.igx-switch') public override cssClass = 'igx-switch'; /** * Sets/gets whether the switch is on or off. @@ -68,7 +73,6 @@ export class IgxSwitchComponent * * ``` */ - @HostBinding('class.igx-switch--checked') @Input() public override set checked(value: boolean) { super.checked = value; @@ -85,9 +89,13 @@ export class IgxSwitchComponent * * ``` */ - @HostBinding('class.igx-switch--disabled') @Input({ transform: booleanAttribute }) - public override disabled = false; + public override get disabled() { + return super.disabled; + } + public override set disabled(value: boolean) { + super.disabled = value; + } /** * Sets/gets whether the switch component is invalid. @@ -101,9 +109,13 @@ export class IgxSwitchComponent * let isInvalid = this.switch.invalid; * ``` */ - @HostBinding('class.igx-switch--invalid') @Input({ transform: booleanAttribute }) - public override invalid = false; + public override get invalid() { + return super.invalid; + } + public override set invalid(value: boolean) { + super.invalid = value; + } /** * Sets/gets whether the switch component is on focus. @@ -114,6 +126,10 @@ export class IgxSwitchComponent * this.switch.focused = true; * ``` */ - @HostBinding('class.igx-switch--focused') - public override focused = false; + public override get focused() { + return super.focused; + } + public override set focused(value: boolean) { + super.focused = value; + } } From e03359d6383315c0926bc89667c08d318451852a Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Mon, 14 Sep 2026 13:28:45 +0300 Subject: [PATCH 4/7] refactor(radio): migrate IgxRadioComponent to OnPush Switch from ChangeDetectionStrategy.Eager to OnPush, now that the state the component renders is signal-backed and marks the view dirty on every write. Also moves the @HostBinding/@HostListener declarations into the decorator's host metadata. The public API is unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- .../radio/src/radio/radio.component.ts | 61 ++++++++++++------- 1 file changed, 38 insertions(+), 23 deletions(-) diff --git a/projects/igniteui-angular/radio/src/radio/radio.component.ts b/projects/igniteui-angular/radio/src/radio/radio.component.ts index 40292e42b65..324dcbf9e42 100644 --- a/projects/igniteui-angular/radio/src/radio/radio.component.ts +++ b/projects/igniteui-angular/radio/src/radio/radio.component.ts @@ -2,8 +2,6 @@ import { AfterViewInit, Component, EventEmitter, - HostBinding, - HostListener, Input, booleanAttribute, OnDestroy, @@ -39,8 +37,18 @@ import { IgxRadioGroupDirective } from './radio-group/radio-group.directive'; templateUrl: 'radio.component.html', styleUrl: 'radio.component.css', encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.Eager, - imports: [IgxRippleDirective] + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IgxRippleDirective], + host: { + '[class.igx-radio]': 'cssClass', + '[class.igx-radio--checked]': 'checked', + '[class.igx-radio--disabled]': 'disabled', + '[class.igx-radio--invalid]': 'invalid', + '[class.igx-radio--focused]': 'focused', + '(change)': '_changed($event)', + '(click)': '_onCheckboxClick()', + '(blur)': 'onBlur()', + } }) export class IgxRadioComponent @@ -59,7 +67,6 @@ export class IgxRadioComponent * * @memberof IgxRadioComponent */ - @HostBinding('class.igx-radio') public override cssClass = 'igx-radio'; /** @@ -74,13 +81,12 @@ export class IgxRadioComponent * * @memberof IgxRadioComponent */ - @HostBinding('class.igx-radio--checked') @Input({ transform: booleanAttribute }) public override set checked(value: boolean) { - this._checked = value; + this._checked.set(value); } public override get checked() { - return this._checked; + return this._checked(); } /** @@ -95,9 +101,13 @@ export class IgxRadioComponent * * @memberof IgxRadioComponent */ - @HostBinding('class.igx-radio--disabled') @Input({ transform: booleanAttribute }) - public override disabled = false; + public override get disabled() { + return super.disabled; + } + public override set disabled(value: boolean) { + super.disabled = value; + } /** * Sets/gets whether the radio button is invalid. @@ -111,9 +121,13 @@ export class IgxRadioComponent * * @memberof IgxRadioComponent */ - @HostBinding('class.igx-radio--invalid') @Input({ transform: booleanAttribute }) - public override invalid = false; + public override get invalid() { + return super.invalid; + } + public override set invalid(value: boolean) { + super.invalid = value; + } /** * Sets/gets whether the radio component is on focus. @@ -127,14 +141,17 @@ export class IgxRadioComponent * * @memberof IgxRadioComponent */ - @HostBinding('class.igx-radio--focused') - public override focused = false; + public override get focused() { + return super.focused; + } + public override set focused(value: boolean) { + super.focused = value; + } /** * @hidden * @internal */ - @HostListener('change', ['$event']) public _changed(event: IChangeCheckboxEventArgs) { if (event instanceof Event) { event.preventDefault(); @@ -144,7 +161,6 @@ export class IgxRadioComponent /** * @hidden */ - @HostListener('click') public override _onCheckboxClick() { this.select(); } @@ -158,12 +174,12 @@ export class IgxRadioComponent * @memberof IgxRadioComponent */ public select() { - if (!this.checked) { - this.checked = true; + if (!this._checked()) { + this._checked.set(true); this.change.emit({ value: this.value, owner: this, - checked: this.checked, + checked: this._checked(), }); this._onChangeCallback(this.value); } @@ -178,7 +194,7 @@ export class IgxRadioComponent * @memberof IgxRadioComponent */ public deselect() { - this.checked = false; + this._checked.set(false); this.focused = false; this.cdr.markForCheck(); } @@ -194,8 +210,8 @@ export class IgxRadioComponent this.value = this.value ?? value; if (value === this.value) { - if (!this.checked) { - this.checked = true; + if (!this._checked()) { + this._checked.set(true); } } else { this.deselect(); @@ -205,7 +221,6 @@ export class IgxRadioComponent /** * @hidden */ - @HostListener('blur') public override onBlur() { super.onBlur(); this.blurRadio.emit(); From 103264cf7dbd95f392093759191e1089fbce3de1 Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Mon, 14 Sep 2026 13:29:22 +0300 Subject: [PATCH 5/7] refactor(checkbox,switch,radio): memoize derived view state Convert the ariaChecked and labelClass getters to memoized computed() signals and read the backing signals directly from the templates. Both getters ran once per binding on every dirty change-detection pass, with labelClass allocating a new string each time. As computed() they recompute only when their dependencies change: measured 10 -> 0 recomputations over 10 dirty passes with labelPosition unchanged. The public ariaChecked/labelClass getters are removed. Both were marked @hidden @internal and were referenced only by these three templates. required deliberately stays a getter: it falls back to nativeElement.hasAttribute('required'), which is not part of the reactive graph, so a computed() would go stale and drop the fallback. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/checkbox/checkbox.component.html | 26 ++++++------- .../checkbox/checkbox-base.directive.ts | 39 +++++++------------ .../radio/src/radio/radio.component.html | 24 ++++++------ .../switch/src/switch/switch.component.html | 24 ++++++------ 4 files changed, 52 insertions(+), 61 deletions(-) diff --git a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.html b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.html index a2fce07ba00..532dd5f2f86 100644 --- a/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.html +++ b/projects/igniteui-angular/checkbox/src/checkbox/checkbox.component.html @@ -1,25 +1,25 @@
+ [class]="_labelClass()" + [id]="_labelId()"> 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 599e2e460b7..57ec0138ce4 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 @@ -1,4 +1,4 @@ -import { Directive, EventEmitter, Input, Output, ViewChild, ElementRef, ChangeDetectorRef, booleanAttribute, inject, AfterViewInit, signal, DestroyRef } from '@angular/core'; +import { Directive, EventEmitter, Input, Output, ViewChild, ElementRef, ChangeDetectorRef, booleanAttribute, inject, AfterViewInit, signal, computed, DestroyRef } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NgControl, Validators } from '@angular/forms'; import { IBaseEventArgs } from 'igniteui-angular/core'; @@ -57,6 +57,20 @@ export class CheckboxBaseDirective implements AfterViewInit { protected readonly _labelPosition = signal(LabelPosition.AFTER); protected readonly _disableRipple = signal(false); + // Derived view state, consumed by the templates. + protected readonly _ariaChecked = computed(() => + this._indeterminate() ? 'mixed' : this._checked() + ); + + // `cssClass` is a per-subclass constant rather than a signal, so it is read + // once and memoized. That is safe only because the first read happens while + // rendering, after the subclass field initializer has assigned it. + protected readonly _labelClass = computed(() => + this._labelPosition() === LabelPosition.BEFORE + ? `${this.cssClass}__label ${this.cssClass}__label--before` + : `${this.cssClass}__label` + ); + /** * An event that is emitted after the checkbox state is changed. * Provides references to the checkbox and the `checked` property as event arguments. @@ -440,18 +454,6 @@ export class CheckboxBaseDirective implements AfterViewInit { }); } - /** - * @hidden - * @internal - */ - public get ariaChecked() { - if (this._indeterminate()) { - return 'mixed'; - } else { - return this._checked(); - } - } - /** @hidden @internal */ public _onCheckboxChange(event: Event) { // We have to stop the original checkbox change event @@ -471,17 +473,6 @@ export class CheckboxBaseDirective implements AfterViewInit { this._checked.set(value); } - /** @hidden @internal */ - public get labelClass(): string { - switch (this._labelPosition()) { - case LabelPosition.BEFORE: - return `${this.cssClass}__label ${this.cssClass}__label--before`; - case LabelPosition.AFTER: - default: - return `${this.cssClass}__label`; - } - } - /** @hidden @internal */ public registerOnChange(fn: (_: any) => void) { this._onChangeCallback = fn; diff --git a/projects/igniteui-angular/radio/src/radio/radio.component.html b/projects/igniteui-angular/radio/src/radio/radio.component.html index 7bf9fb5cb06..2ee3242d376 100644 --- a/projects/igniteui-angular/radio/src/radio/radio.component.html +++ b/projects/igniteui-angular/radio/src/radio/radio.component.html @@ -1,28 +1,28 @@
+ [id]="_labelId()" + [class]="_labelClass()"> diff --git a/projects/igniteui-angular/switch/src/switch/switch.component.html b/projects/igniteui-angular/switch/src/switch/switch.component.html index e525680a699..c3c2bbb3300 100644 --- a/projects/igniteui-angular/switch/src/switch/switch.component.html +++ b/projects/igniteui-angular/switch/src/switch/switch.component.html @@ -1,23 +1,23 @@
@@ -27,7 +27,7 @@ + [class]="_labelClass()" + [id]="_labelId()"> From 7f7c17bea69f63663a3200e670a7add138a5bbec Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Mon, 14 Sep 2026 13:29:22 +0300 Subject: [PATCH 6/7] fix(radio-group): release subscriptions when a radio button is destroyed _setRadioButtonEvents subscribed to each button's change/blurRadio/keyup streams but only tore them down when the whole group was destroyed, so cycling buttons through a structural directive accumulated subscriptions for the lifetime of the group. The existing takeUntil(button.destroy$) never fired - destroy$ was declared on CheckboxBaseDirective but never completed - so the intended per-button cleanup was inert. Scope the subscriptions to the button's own DestroyRef instead, and cover it with a regression test. Co-Authored-By: Claude Opus 5 (1M context) --- .../radio-group/radio-group.directive.spec.ts | 18 ++++++++++++++++++ .../radio/radio-group/radio-group.directive.ts | 15 +++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) 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 39f827412f6..aedc7c6a753 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 @@ -217,6 +217,24 @@ describe('IgxRadioGroupDirective', () => { expect(radioInstance.selected).toEqual(radioInstance.radioButtons.last); })); + it('Releases its subscriptions to a radio button that is removed from the group', fakeAsync(() => { + const fixture = TestBed.createComponent(RadioGroupDeepProjectionComponent); + fixture.detectChanges(); + tick(); + + const removed = fixture.componentInstance.radioGroup.radioButtons.last; + expect(removed.change.observed).toBe(true); + + fixture.componentInstance.choices = [0, 1]; + fixture.detectChanges(); + tick(); + + // The group must not keep listening to a button it no longer owns, + // otherwise subscriptions accumulate for the lifetime of the group. + expect(removed.change.observed).toBe(false); + expect(removed.blurRadio.observed).toBe(false); + })); + it('Updates checked radio button correctly', fakeAsync(() => { const fixture = TestBed.createComponent(RadioGroupSimpleComponent); 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 60cb45870fd..7a9b36cc768 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 @@ -15,6 +15,7 @@ import { inject, ElementRef } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; import { fromEvent, noop, Subject, takeUntil } from 'rxjs'; import { IgxRadioComponent } from '../radio.component'; @@ -549,19 +550,25 @@ export class IgxRadioGroupDirective implements ControlValueAccessor, OnDestroy, * @hidden * @internal */ - private _setRadioButtonEvents(button: any) { + private _setRadioButtonEvents(button: IgxRadioComponent) { button.change.pipe( - takeUntil(button.destroy$), + takeUntilDestroyed(button.destroyRef), takeUntil(this.destroy$), takeUntil(this.queryChange$) ).subscribe((ev: IChangeCheckboxEventArgs) => this._selectedRadioButtonChanged(ev)); button.blurRadio - .pipe(takeUntil(this.destroy$)) + .pipe( + takeUntilDestroyed(button.destroyRef), + takeUntil(this.destroy$) + ) .subscribe(() => this.updateValidityOnBlur()); fromEvent(button.nativeElement, 'keyup') - .pipe(takeUntil(this.destroy$)) + .pipe( + takeUntilDestroyed(button.destroyRef), + takeUntil(this.destroy$) + ) .subscribe((event: KeyboardEvent) => this.updateOnKeyUp(event)); } From 9e25c7eb6d942ab536f1eb26aeeb6bc45988aa0a Mon Sep 17 00:00:00 2001 From: Martin Dragnev Date: Mon, 14 Sep 2026 13:33:45 +0300 Subject: [PATCH 7/7] docs(changelog): add 22.2.0 entries for the checkbox family Document the OnPush switch for the checkbox, switch and radio components under Behavioral Changes, and the radio group's per-button subscription cleanup under Bug Fixes. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6448d2b1946..222746f121e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,11 +24,14 @@ All notable changes for each version of this project will be documented in this ### Behavioral Changes +- `IgxCheckboxComponent`, `IgxSwitchComponent`, `IgxRadioComponent` - the three components now use `ChangeDetectionStrategy.OnPush` instead of `Eager`. Their internal state is backed by Angular signals, so each component marks itself for check whenever that state is written, and is no longer re-checked on every application-wide change detection pass. The public `@Input()`/`@Output()` API is unchanged, and the components keep reflecting state written directly on the instance, e.g. `checkbox.checked = true`. - **Theming** - Scrollbar arrow buttons cannot be styled or enabled through the standard properties, and `scrollbar-width: thin` removes them where the platform draws them. - **Firefox** - The `scrollbar-color` and `scrollbar-width` properties are not supported on Firefox versions prior to 64, so the scrollbars in those versions will render with the platform default colors and size. ### Bug Fixes +- `IgxRadioGroupDirective` + - The group's subscriptions to a radio button's events are now released when that button itself is destroyed, instead of living until the whole group is destroyed. Previously, radio buttons added and removed dynamically - for example through `@for` - leaked a subscription per button for the lifetime of the group. - `IgxCheckboxComponent` - Fixed the tick-mark icon rendering with the Indigo shape (rounded rect + custom path) inside CSS-scoped subtrees that use a different design system than the application's global theme, e.g. a `material`-themed widget nested inside an `indigo`-themed app. Both tick-mark variants are now always rendered and toggled purely via CSS (`@container style(--ig-theme: indigo)`), removing the dependency on JS-side theme detection that could go stale in nested/multi-theme scenarios (#15021). - **Ripple**