Skip to content

Commit 5968561

Browse files
dylhunnpkozlowski-opensource
authored andcommitted
fix(forms): Make radio buttons respect [attr.disabled] (#48864)
`setDisabledState` is supposed to be called whenever the disabled state of a control changes, including upon control creation. However, a longstanding bug caused the method to not fire when an *enabled* control was attached. This bug was fixed in v15. This had a side effect: previously, it was possible to instantiate a reactive form control with `[attr.disabled]=true`, even though the the corresponding control was enabled in the model. (Note that the similar-looking property binding version `[disabled]=true` was always rejected, though.) This resulted in a mismatch between the model and the DOM. Now, because `setDisabledState` is always called, the value in the DOM will be immediately overwritten with the "correct" enabled value. Users should instead disable the control directly in their model. (There are many ways to do this, such as using the `{value: 'foo', disabled: true}` constructor format, or immediately calling `FooControl.disable()` in `ngOnInit`.) If this incompatibility is too breaking, you may also opt out using `FormsModule.withConfig` or `ReactiveFormsModule.withConfig` at the time you import it, via the `callSetDisabledState` option. However, there is an exceptional case: radio buttons. Because Reactive Forms models the entire group of radio buttons as a single `FormControl`, there is no way to control the disabled state for individual radios, so they can no longer be configured as disabled. In this PR, we have special cased radio buttons to ignore their first call to `setDisabledState` when in `callSetDisabledState: 'always'` mode. This preserves the old behavior. PR Close #48864
1 parent c194867 commit 5968561

File tree

3 files changed

+87
-1
lines changed

3 files changed

+87
-1
lines changed

goldens/public-api/forms/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,8 @@ export class RadioControlValueAccessor extends BuiltInControlValueAccessor imple
781781
ngOnInit(): void;
782782
onChange: () => void;
783783
registerOnChange(fn: (_: any) => {}): void;
784+
// (undocumented)
785+
setDisabledState(isDisabled: boolean): void;
784786
value: any;
785787
writeValue(value: any): void;
786788
// (undocumented)

packages/forms/src/directives/radio_control_value_accessor.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Directive, ElementRef, forwardRef, Injectable, Injector, Input, NgModule, OnDestroy, OnInit, Provider, Renderer2, ɵRuntimeError as RuntimeError} from '@angular/core';
9+
import {Directive, ElementRef, forwardRef, inject, Injectable, Injector, Input, NgModule, OnDestroy, OnInit, Provider, Renderer2, ɵRuntimeError as RuntimeError} from '@angular/core';
1010

1111
import {RuntimeErrorCode} from '../errors';
1212

1313
import {BuiltInControlValueAccessor, ControlValueAccessor, NG_VALUE_ACCESSOR} from './control_value_accessor';
1414
import {NgControl} from './ng_control';
15+
import {CALL_SET_DISABLED_STATE, setDisabledStateDefault, SetDisabledStateOption} from './shared';
1516

1617
const RADIO_VALUE_ACCESSOR: Provider = {
1718
provide: NG_VALUE_ACCESSOR,
@@ -124,6 +125,8 @@ export class RadioControlValueAccessor extends BuiltInControlValueAccessor imple
124125
// TODO(issue/24571): remove '!'.
125126
_fn!: Function;
126127

128+
private setDisabledStateFired = false;
129+
127130
/**
128131
* The registered callback function called when a change event occurs on the input element.
129132
* Note: we declare `onChange` here (also used as host listener) as a function with no arguments
@@ -154,6 +157,9 @@ export class RadioControlValueAccessor extends BuiltInControlValueAccessor imple
154157
*/
155158
@Input() value: any;
156159

160+
private callSetDisabledState =
161+
inject(CALL_SET_DISABLED_STATE, {optional: true}) ?? setDisabledStateDefault;
162+
157163
constructor(
158164
renderer: Renderer2, elementRef: ElementRef, private _registry: RadioControlRegistry,
159165
private _injector: Injector) {
@@ -193,6 +199,33 @@ export class RadioControlValueAccessor extends BuiltInControlValueAccessor imple
193199
};
194200
}
195201

202+
/** @nodoc */
203+
override setDisabledState(isDisabled: boolean): void {
204+
/**
205+
* `setDisabledState` is supposed to be called whenever the disabled state of a control changes,
206+
* including upon control creation. However, a longstanding bug caused the method to not fire
207+
* when an *enabled* control was attached. This bug was fixed in v15 in #47576.
208+
*
209+
* This had a side effect: previously, it was possible to instantiate a reactive form control
210+
* with `[attr.disabled]=true`, even though the the corresponding control was enabled in the
211+
* model. This resulted in a mismatch between the model and the DOM. Now, because
212+
* `setDisabledState` is always called, the value in the DOM will be immediately overwritten
213+
* with the "correct" enabled value.
214+
*
215+
* However, the fix also created an exceptional case: radio buttons. Because Reactive Forms
216+
* models the entire group of radio buttons as a single `FormControl`, there is no way to
217+
* control the disabled state for individual radios, so they can no longer be configured as
218+
* disabled. Thus, we keep the old behavior for radio buttons, so that `[attr.disabled]`
219+
* continues to work. Specifically, we drop the first call to `setDisabledState` if `disabled`
220+
* is `false`, and we are not in legacy mode.
221+
*/
222+
if (this.setDisabledStateFired || isDisabled ||
223+
this.callSetDisabledState === 'whenDisabledForLegacyCode') {
224+
this.setProperty('disabled', isDisabled);
225+
}
226+
this.setDisabledStateFired = true;
227+
}
228+
196229
/**
197230
* Sets the "value" on the radio input element and unchecks it.
198231
*

packages/forms/test/reactive_integration_spec.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3634,6 +3634,42 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
36343634
});
36353635
});
36363636

3637+
describe('radio groups', () => {
3638+
it('should respect the attr.disabled state as an initial value', () => {
3639+
const fixture = initTest(RadioForm);
3640+
const choice = new FormControl('one');
3641+
const form = new FormGroup({'choice': choice});
3642+
fixture.componentInstance.form = form;
3643+
fixture.detectChanges();
3644+
3645+
const oneInput = fixture.debugElement.query(By.css('input'));
3646+
3647+
// The 'one' input is initially disabled in the view with [attr.disabled].
3648+
// The model thinks it is enabled. This inconsistent state was retained for backwards
3649+
// compatibility. (See `RadioControlValueAccessor` for details.)
3650+
expect(oneInput.attributes.disabled).toBe('true');
3651+
expect(choice.disabled).toBe(false);
3652+
3653+
// Flipping the attribute back and forth does not change the model
3654+
// as expected. Once the initial `disabled` value is setup, the model
3655+
// becomes the source of truth.
3656+
oneInput.nativeElement.setAttribute('disabled', 'false');
3657+
fixture.detectChanges();
3658+
expect(oneInput.attributes.disabled).toBe('false');
3659+
expect(choice.disabled).toBe(false);
3660+
3661+
oneInput.nativeElement.setAttribute('disabled', 'true');
3662+
fixture.detectChanges();
3663+
expect(oneInput.attributes.disabled).toBe('true');
3664+
expect(choice.disabled).toBe(false);
3665+
3666+
oneInput.nativeElement.setAttribute('disabled', 'false');
3667+
fixture.detectChanges();
3668+
expect(oneInput.attributes.disabled).toBe('false');
3669+
expect(choice.disabled).toBe(false);
3670+
});
3671+
});
3672+
36373673
describe('IME events', () => {
36383674
it('should determine IME event handling depending on platform by default', () => {
36393675
const fixture = initTest(FormControlComp);
@@ -5579,3 +5615,18 @@ class NativeDialogForm {
55795615
@ViewChild('form') form!: ElementRef<HTMLFormElement>;
55805616
formGroup = new FormGroup({});
55815617
}
5618+
5619+
@Component({
5620+
selector: 'radio-form',
5621+
template: `
5622+
<form [formGroup]="form">
5623+
<input type="radio" formControlName="choice" value="one" [attr.disabled]="true"> One
5624+
<input type="radio" formControlName="choice" value="two"> Two
5625+
</form>
5626+
`,
5627+
})
5628+
export class RadioForm {
5629+
form = new FormGroup({
5630+
choice: new FormControl('one'),
5631+
});
5632+
}

0 commit comments

Comments
 (0)