Skip to content

fix(radio-group): dynamically added radio buttons do not initialize #16021

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 10, 2025
Merged
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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core';
import { ChangeDetectionStrategy, Component, ComponentRef, OnInit, ViewChild, ViewContainerRef } from '@angular/core';
import { TestBed, fakeAsync, tick, waitForAsync } from '@angular/core/testing';
import { IgxRadioGroupDirective } from './radio-group.directive';
import { FormsModule, ReactiveFormsModule, UntypedFormGroup, UntypedFormBuilder, FormGroup, FormControl } from '@angular/forms';
Expand All @@ -20,7 +20,9 @@ describe('IgxRadioGroupDirective', () => {
RadioGroupWithModelComponent,
RadioGroupRequiredComponent,
RadioGroupReactiveFormsComponent,
RadioGroupDeepProjectionComponent
RadioGroupDeepProjectionComponent,
RadioGroupTestComponent,
DynamicRadioGroupComponent
]
})
.compileComponents();
Expand Down Expand Up @@ -69,13 +71,15 @@ describe('IgxRadioGroupDirective', () => {
// name
radioInstance.name = 'newGroupName';
fixture.detectChanges();
tick();

const allButtonsWithNewName = radioInstance.radioButtons.filter((btn) => btn.name === 'newGroupName');
expect(allButtonsWithNewName.length).toEqual(radioInstance.radioButtons.length);

// required
radioInstance.required = true;
fixture.detectChanges();
tick();

const allRequiredButtons = radioInstance.radioButtons.filter((btn) => btn.required);
expect(allRequiredButtons.length).toEqual(radioInstance.radioButtons.length);
Expand Down Expand Up @@ -261,6 +265,38 @@ describe('IgxRadioGroupDirective', () => {
expect(radioGroup.radioButtons.first.checked).toEqual(true);
expect(domRadio.classList.contains('igx-radio--invalid')).toBe(false);
}));

it('Should select radio button when added programmatically after group value is set', (() => {
const fixture = TestBed.createComponent(DynamicRadioGroupComponent);
const component = fixture.componentInstance;
const radioGroup = component.radioGroup;

// Simulate AppBuilder configurator setting value before radio buttons exist
radioGroup.value = 'option2';

// Verify no radio buttons exist yet
expect(radioGroup.radioButtons.length).toBe(0);
expect(radioGroup.selected).toBeNull();

fixture.detectChanges();

component.addRadioButton('option1', 'Option 1');
component.addRadioButton('option2', 'Option 2');
component.addRadioButton('option3', 'Option 3');

fixture.detectChanges();

// Radio button with value 'option2' should be selected
expect(radioGroup.value).toBe('option2');
expect(radioGroup.selected).toBeDefined();
expect(radioGroup.selected.value).toBe('option2');
expect(radioGroup.selected.checked).toBe(true);

// Verify only one radio button is selected
const checkedButtons = radioGroup.radioButtons.filter(btn => btn.checked);
expect(checkedButtons.length).toBe(1);
expect(checkedButtons[0].value).toBe('option2');
}));
});

@Component({
Expand Down Expand Up @@ -444,8 +480,75 @@ class RadioGroupDeepProjectionComponent {
}
}

@Component({
template: `
<igx-radio-group
[alignment]="alignment"
[required]="required"
[value]="value"
(change)="handleChange($event)"
>
<ng-container #radioContainer></ng-container>
</igx-radio-group>
`,
imports: [IgxRadioComponent, IgxRadioGroupDirective]
})

class RadioGroupTestComponent implements OnInit {
@ViewChild('radioContainer', { read: ViewContainerRef, static: true })
public container!: ViewContainerRef;

public alignment = 'horizontal';
public required = false;
public value: any;

public radios: { label: string; value: any }[] = [];

public handleChange(args: any) {
this.value = args.value;
}

public ngOnInit(): void {
this.container.clear();
this.radios.forEach((option) => {
const componentRef: ComponentRef<IgxRadioComponent> =
this.container.createComponent(IgxRadioComponent);

componentRef.instance.placeholderLabel.nativeElement.textContent =
option.label;
componentRef.instance.value = option.value;
});
}
}

@Component({
template: `
<igx-radio-group #radioGroup>
<ng-container #radioContainer></ng-container>
</igx-radio-group>
`,
imports: [IgxRadioGroupDirective, IgxRadioComponent]
})
class DynamicRadioGroupComponent {
@ViewChild('radioGroup', { read: IgxRadioGroupDirective, static: true })
public radioGroup: IgxRadioGroupDirective;

@ViewChild('radioContainer', { read: ViewContainerRef, static: true })
public radioContainer: ViewContainerRef;

/**
* Simulates how AppBuilder adds radio buttons programmatically
* via ViewContainerRef.createComponent()
*/
public addRadioButton(value: string, label: string): void {
const componentRef = this.radioContainer.createComponent(IgxRadioComponent);
componentRef.instance.value = value;
componentRef.instance.placeholderLabel.nativeElement.textContent = label;
componentRef.changeDetectorRef.detectChanges();
}
}

const dispatchRadioEvent = (eventName, radioNativeElement, fixture) => {
radioNativeElement.dispatchEvent(new Event(eventName));
fixture.detectChanges();
};

Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
QueryList,
Self,
booleanAttribute,
contentChildren,
effect,
signal
} from '@angular/core';
Expand Down Expand Up @@ -62,7 +61,7 @@ let nextId = 0;
standalone: true
})
export class IgxRadioGroupDirective implements ControlValueAccessor, OnDestroy, DoCheck {
private _radioButtons = contentChildren(IgxRadioComponent, { descendants: true });
private _radioButtons = signal<IgxRadioComponent[]>([]);
private _radioButtonsList = new QueryList<IgxRadioComponent>();

/**
Expand All @@ -74,8 +73,7 @@ export class IgxRadioGroupDirective implements ControlValueAccessor, OnDestroy,
* ```
*/
public get radioButtons(): QueryList<IgxRadioComponent> {
const buttons = Array.from(this._radioButtons());
this._radioButtonsList.reset(buttons);
this._radioButtonsList.reset(this._radioButtons());
return this._radioButtonsList;
}

Expand Down Expand Up @@ -492,10 +490,7 @@ export class IgxRadioGroupDirective implements ControlValueAccessor, OnDestroy,

effect(() => {
this.initialize();

Promise.resolve().then(() => {
this.setRadioButtons();
});
this.setRadioButtons();
});
}

Expand Down Expand Up @@ -533,8 +528,10 @@ export class IgxRadioGroupDirective implements ControlValueAccessor, OnDestroy,
*/
private setRadioButtons() {
this._radioButtons().forEach((button) => {
button.name = this._name;
button.required = this._required;
Promise.resolve().then(() => {
button.name = this._name;
button.required = this._required;
});

if (button.value === this._value) {
button.checked = true;
Expand Down Expand Up @@ -646,6 +643,34 @@ export class IgxRadioGroupDirective implements ControlValueAccessor, OnDestroy,
}
}


/**
* Registers a radio button with this radio group.
* This method is called by radio button components when they are created.
* @hidden @internal
*/
public _addRadioButton(radioButton: IgxRadioComponent): void {
this._radioButtons.update(buttons => {
if (!buttons.includes(radioButton)) {
this._setRadioButtonEvents(radioButton);

return [...buttons, radioButton];
}
return buttons;
});
}

/**
* Unregisters a radio button from this radio group.
* This method is called by radio button components when they are destroyed.
* @hidden @internal
*/
public _removeRadioButton(radioButton: IgxRadioComponent): void {
this._radioButtons.update(buttons =>
buttons.filter(btn => btn !== radioButton)
);
}

/**
* @hidden
* @internal
Expand Down
33 changes: 31 additions & 2 deletions projects/igniteui-angular/src/lib/radio/radio.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import {
HostBinding,
HostListener,
Input,
booleanAttribute
booleanAttribute,
OnDestroy,
inject
} from '@angular/core';
import { ControlValueAccessor } from '@angular/forms';
import { EditorProvider, EDITOR_PROVIDER } from '../core/edit-provider';
import { IgxRippleDirective } from '../directives/ripple/ripple.directive';
import { CheckboxBaseDirective } from '../checkbox/checkbox-base.directive';
import { IgxRadioGroupDirective } from '../directives/radio/radio-group.directive';

/**
* **Ignite UI for Angular Radio Button** -
Expand All @@ -37,10 +40,12 @@ import { CheckboxBaseDirective } from '../checkbox/checkbox-base.directive';
})
export class IgxRadioComponent
extends CheckboxBaseDirective
implements AfterViewInit, ControlValueAccessor, EditorProvider {
implements AfterViewInit, OnDestroy, ControlValueAccessor, EditorProvider {
/** @hidden @internal */
public blurRadio = new EventEmitter();

private radioGroup = inject(IgxRadioGroupDirective, { optional: true, skipSelf: true });

/**
* Returns the class of the radio component.
* ```typescript
Expand Down Expand Up @@ -200,4 +205,28 @@ export class IgxRadioComponent
super.onBlur();
this.blurRadio.emit();
}

/**
* @hidden
* @internal
*/
public override ngAfterViewInit(): void {
super.ngAfterViewInit();

// Register with parent radio group if it exists
if (this.radioGroup) {
this.radioGroup._addRadioButton(this);
}
}

/**
* @hidden
* @internal
*/
public ngOnDestroy(): void {
// Unregister from parent radio group if it exists
if (this.radioGroup) {
this.radioGroup._removeRadioButton(this);
}
}
}
8 changes: 8 additions & 0 deletions src/app/radio/radio-group.component.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<igx-radio-group
[alignment]="alignment"
[required]="required"
[value]="value"
(change)="handleChange($event)"
>
<ng-container #radioContainer></ng-container>
</igx-radio-group>
46 changes: 46 additions & 0 deletions src/app/radio/radio-group.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {
Component,
Input,
ViewChild,
ComponentRef,
ViewContainerRef,
OnInit,
} from '@angular/core';
import {
IChangeCheckboxEventArgs,
IgxRadioComponent,
IgxRadioGroupDirective,
RadioGroupAlignment,
} from 'igniteui-angular';

@Component({
selector: 'app-radio-group',
templateUrl: './radio-group.component.html',
imports: [IgxRadioGroupDirective],
})
export class RadioGroupComponent implements OnInit {
@Input() public alignment!: RadioGroupAlignment;
@Input() public required!: boolean;
@Input() public value!: unknown;

public handleChange(args: IChangeCheckboxEventArgs) {
this.value = args.value;
}

@ViewChild('radioContainer', { read: ViewContainerRef, static: true })
public container!: ViewContainerRef;

@Input() public radios: { label: string; value: any }[] = [];

public ngOnInit(): void {
this.container.clear();
this.radios.forEach((option) => {
const componentRef: ComponentRef<IgxRadioComponent> =
this.container.createComponent(IgxRadioComponent);

componentRef.instance.placeholderLabel.nativeElement.textContent =
option.label;
componentRef.instance.value = option.value;
});
}
}
11 changes: 10 additions & 1 deletion src/app/radio/radio.sample.html
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,15 @@ <h4 class="sample-title">Radio group in reactive form</h4>
</form>
<p>Form value: {{ personKirkForm.value | json }}</p>
<p>Model value: {{ personKirk | json }}</p>
<p>Updated model: {{ newPerson | json }}</p>
<p>Updated model:r{{ newPerson | json }}</p>
</article>
</section>
<section class="sample-content">
<article class="sample-column">
<h4 class="sample-title">Dynamically Create Radio Group</h4>
<button igxButton="contained" (buttonClick)="createRadioGroupComponent()" style="width: 200px">
Create Radio Group
</button>
<ng-template #container></ng-template>
</article>
</section>
4 changes: 4 additions & 0 deletions src/app/radio/radio.sample.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
igx-radio-group {
width: initial;
}

.sample-content {
flex-flow: column nowrap;
}
Expand Down
Loading
Loading