Skip to content
Closed
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
2 changes: 2 additions & 0 deletions goldens/public-api/forms/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,8 @@ export class RadioControlValueAccessor extends BuiltInControlValueAccessor imple
ngOnInit(): void;
onChange: () => void;
registerOnChange(fn: (_: any) => {}): void;
// (undocumented)
setDisabledState(isDisabled: boolean): void;
value: any;
writeValue(value: any): void;
// (undocumented)
Expand Down
35 changes: 34 additions & 1 deletion packages/forms/src/directives/radio_control_value_accessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@
* found in the LICENSE file at https://angular.io/license
*/

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

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

import {BuiltInControlValueAccessor, ControlValueAccessor, NG_VALUE_ACCESSOR} from './control_value_accessor';
import {NgControl} from './ng_control';
import {CALL_SET_DISABLED_STATE, setDisabledStateDefault, SetDisabledStateOption} from './shared';

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

private setDisabledStateFired = false;

/**
* The registered callback function called when a change event occurs on the input element.
* Note: we declare `onChange` here (also used as host listener) as a function with no arguments
Expand Down Expand Up @@ -154,6 +157,9 @@ export class RadioControlValueAccessor extends BuiltInControlValueAccessor imple
*/
@Input() value: any;

private callSetDisabledState =
inject(CALL_SET_DISABLED_STATE, {optional: true}) ?? setDisabledStateDefault;

constructor(
renderer: Renderer2, elementRef: ElementRef, private _registry: RadioControlRegistry,
private _injector: Injector) {
Expand Down Expand Up @@ -193,6 +199,33 @@ export class RadioControlValueAccessor extends BuiltInControlValueAccessor imple
};
}

/** @nodoc */
override setDisabledState(isDisabled: boolean): void {
Comment thread
dylhunn marked this conversation as resolved.
Resolved
/**
* `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 in #47576.
*
* 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. 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.
*
* However, the fix also created 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. Thus, we keep the old behavior for radio buttons, so that `[attr.disabled]`
* continues to work. Specifically, we drop the first call to `setDisabledState` if `disabled`
* is `false`, and we are not in legacy mode.
*/
if (this.setDisabledStateFired || isDisabled ||
this.callSetDisabledState === 'whenDisabledForLegacyCode') {
this.setProperty('disabled', isDisabled);
}
this.setDisabledStateFired = true;
}

/**
* Sets the "value" on the radio input element and unchecks it.
*
Expand Down
51 changes: 51 additions & 0 deletions packages/forms/test/reactive_integration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3498,6 +3498,42 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
});
});

describe('radio groups', () => {
it('should respect the attr.disabled state as an initial value', () => {
const fixture = initTest(RadioForm);
Comment thread
dylhunn marked this conversation as resolved.
Resolved
const choice = new FormControl('one');
const form = new FormGroup({'choice': choice});
fixture.componentInstance.form = form;
fixture.detectChanges();
Comment thread
dylhunn marked this conversation as resolved.
Resolved

const oneInput = fixture.debugElement.query(By.css('input'));

// The 'one' input is initially disabled in the view with [attr.disabled].
// The model thinks it is enabled. This inconsistent state was retained for backwards
// compatibility. (See `RadioControlValueAccessor` for details.)
expect(oneInput.attributes.disabled).toBe('true');
expect(choice.disabled).toBe(false);

// Flipping the attribute back and forth does not change the model
// as expected. Once the initial `disabled` value is setup, the model
// becomes the source of truth.
oneInput.nativeElement.setAttribute('disabled', 'false');
fixture.detectChanges();
expect(oneInput.attributes.disabled).toBe('false');
expect(choice.disabled).toBe(false);

oneInput.nativeElement.setAttribute('disabled', 'true');
fixture.detectChanges();
expect(oneInput.attributes.disabled).toBe('true');
expect(choice.disabled).toBe(false);

oneInput.nativeElement.setAttribute('disabled', 'false');
fixture.detectChanges();
expect(oneInput.attributes.disabled).toBe('false');
expect(choice.disabled).toBe(false);
});
});

describe('IME events', () => {
it('should determine IME event handling depending on platform by default', () => {
const fixture = initTest(FormControlComp);
Expand Down Expand Up @@ -5467,3 +5503,18 @@ class NativeDialogForm {
@ViewChild('form') form!: ElementRef<HTMLFormElement>;
formGroup = new FormGroup({});
}

@Component({
selector: 'radio-form',
template: `
<form [formGroup]="form">
<input type="radio" formControlName="choice" value="one" [attr.disabled]="true"> One
<input type="radio" formControlName="choice" value="two"> Two
</form>
`,
})
export class RadioForm {
form = new FormGroup({
choice: new FormControl('one'),
});
}