mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
fix(forms): keep radio inputs in sync when values change
Recompute the checked state when a reused radio input receives a new value while the form model remains unchanged. Handle this case for both reactive forms and signal forms, and add regression tests covering reused radio elements.
This commit is contained in:
@@ -94,7 +94,7 @@ export function nativeControlCreate(
|
||||
);
|
||||
}
|
||||
|
||||
const bindings = createBindings<ControlBindingKey | 'controlValue'>();
|
||||
const bindings = createBindings<ControlBindingKey | 'controlValue' | 'radioValue'>();
|
||||
|
||||
return () => {
|
||||
const state = parent.state();
|
||||
@@ -117,7 +117,11 @@ export function nativeControlCreate(
|
||||
|
||||
// We need to update the value after setting the attributes as some attributes like min/max might prevent from setting the value
|
||||
const controlValue = state.controlValue();
|
||||
if (bindingUpdated(bindings, 'controlValue', controlValue)) {
|
||||
const controlValueChanged = bindingUpdated(bindings, 'controlValue', controlValue);
|
||||
const radioValueChanged =
|
||||
input.type === 'radio' && bindingUpdated(bindings, 'radioValue', input.value);
|
||||
|
||||
if (controlValueChanged || radioValueChanged) {
|
||||
setNativeControlValue(input, controlValue);
|
||||
}
|
||||
|
||||
|
||||
@@ -4235,6 +4235,46 @@ describe('field directive', () => {
|
||||
expect(cmp.f().value()).toBe(ABC.B);
|
||||
});
|
||||
|
||||
it('synchronizes the checked state when a reused radio changes value', async () => {
|
||||
interface RadioOption {
|
||||
readonly id: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
imports: [FormField],
|
||||
template: `
|
||||
@for (option of options(); track option.id) {
|
||||
<input type="radio" [formField]="f" [value]="option.value" />
|
||||
}
|
||||
`,
|
||||
})
|
||||
class TestCmp {
|
||||
readonly f = form(signal('selected'), {name: 'test'});
|
||||
readonly options = signal<ReadonlyArray<RadioOption>>([
|
||||
{id: 'shared', value: 'other'},
|
||||
{id: 'old', value: 'selected'},
|
||||
]);
|
||||
}
|
||||
|
||||
const fixture = TestBed.createComponent(TestCmp);
|
||||
await fixture.whenStable();
|
||||
const getCheckedStates = () =>
|
||||
Array.from(
|
||||
(fixture.nativeElement as HTMLElement).querySelectorAll<HTMLInputElement>('input'),
|
||||
).map((input) => input.checked);
|
||||
|
||||
expect(getCheckedStates()).toEqual([false, true]);
|
||||
|
||||
fixture.componentInstance.options.set([
|
||||
{id: 'new', value: 'other'},
|
||||
{id: 'shared', value: 'selected'},
|
||||
]);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(getCheckedStates()).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('synchronizes with a textarea', () => {
|
||||
@Component({
|
||||
imports: [FormField],
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
OnInit,
|
||||
Provider,
|
||||
Renderer2,
|
||||
SimpleChanges,
|
||||
ɵRuntimeError as RuntimeError,
|
||||
Service,
|
||||
} from '@angular/core';
|
||||
@@ -179,6 +180,15 @@ export class RadioControlValueAccessor
|
||||
super(renderer, elementRef);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
const control = this._control?.control;
|
||||
|
||||
if (changes['value'] && control) {
|
||||
this.writeValue(control.value);
|
||||
}
|
||||
}
|
||||
|
||||
/** @docs-private */
|
||||
ngOnInit(): void {
|
||||
this._control = this._injector.get(NgControl);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
NgZone,
|
||||
Output,
|
||||
RendererFactory2,
|
||||
signal,
|
||||
Type,
|
||||
ViewChild,
|
||||
} from '@angular/core';
|
||||
@@ -660,6 +661,22 @@ describe('value accessors', () => {
|
||||
expect(inputs[1].nativeElement.checked).toEqual(true);
|
||||
});
|
||||
|
||||
it('should update the checked state when a reused radio changes value', async () => {
|
||||
const fixture = initTest(DynamicRadioForm);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(getRadioCheckedStates(fixture)).toEqual([false, true]);
|
||||
|
||||
fixture.componentInstance.form.patchValue({answer: 'selected'});
|
||||
fixture.componentInstance.options.set([
|
||||
{id: 'new', value: 'other'},
|
||||
{id: 'shared', value: 'selected'},
|
||||
]);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(getRadioCheckedStates(fixture)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
it('should support an initial undefined value', () => {
|
||||
const fixture = initTest(FormControlRadioButtons);
|
||||
const form = new FormGroup({'food': new FormControl(), 'drink': new FormControl()});
|
||||
@@ -2047,6 +2064,41 @@ export class FormControlRadioButtons {
|
||||
showRadio = new FormControl('yes');
|
||||
}
|
||||
|
||||
interface RadioOption {
|
||||
readonly id: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'dynamic-radio-form',
|
||||
template: `
|
||||
<form [formGroup]="form">
|
||||
@for (option of options(); track option.id) {
|
||||
<input type="radio" formControlName="answer" [value]="option.value" />
|
||||
}
|
||||
</form>
|
||||
`,
|
||||
standalone: false,
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
})
|
||||
class DynamicRadioForm {
|
||||
readonly form = new FormGroup({
|
||||
answer: new FormControl('selected', {nonNullable: true}),
|
||||
});
|
||||
readonly options = signal<ReadonlyArray<RadioOption>>([
|
||||
{id: 'shared', value: 'other'},
|
||||
{id: 'old', value: 'selected'},
|
||||
]);
|
||||
}
|
||||
|
||||
function getRadioCheckedStates(fixture: ComponentFixture<DynamicRadioForm>): boolean[] {
|
||||
const element = fixture.nativeElement as HTMLElement;
|
||||
|
||||
return Array.from(element.querySelectorAll<HTMLInputElement>('input')).map(
|
||||
(input) => input.checked,
|
||||
);
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'ng-model-radio-form',
|
||||
template: `
|
||||
|
||||
Reference in New Issue
Block a user