Skip to content
Open
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
4 changes: 3 additions & 1 deletion src/material/radio/radio.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ Individual radio-buttons inside of a radio-group will inherit the `name` of the

### Use with Angular Forms
`<mat-radio-group>` is compatible with `@angular/forms` and supports `FormField`, `FormsModule`,
and `ReactiveFormsModule`.
and `ReactiveFormsModule`. In addition, `<mat-radio-group>` supports a `compareWith` function to customize
how option values are compared with the form value (for example, when using complex objects). Additional
information can be found in the [Angular forms documentation](https://angular.dev/api/forms/SelectControlValueAccessor#compareWith).

### Accessibility

Expand Down
63 changes: 63 additions & 0 deletions src/material/radio/radio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,43 @@ describe('MatRadioDefaultOverrides', () => {
expect(radioDebugElement.nativeElement.classList).toContain('mat-warn');
});
});

describe('using compareWith', () => {
let fixture: ComponentFixture<RadioGroupWithCompareWith>;
let instance: RadioGroupWithCompareWith;

beforeEach(() => {
fixture = TestBed.createComponent(RadioGroupWithCompareWith);
fixture.detectChanges();
instance = fixture.componentInstance;
});

it('should select radio button matching the comparator', () => {
const buttons = instance.buttons.toArray();
expect(buttons[0].checked).toBe(false);
expect(buttons[1].checked).toBe(true);
expect(buttons[2].checked).toBe(false);
expect(instance.group.selected).toBe(buttons[1]);
});

it('should update selection when comparator is changed', () => {
instance.compareFn = (a: any, b: any) => a === b;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();

const buttons = instance.buttons.toArray();
expect(buttons[1].checked).toBe(false);
expect(instance.group.selected).toBeNull();
});

it('should throw an error when compareWith is not a function', () => {
expect(() => {
instance.compareFn = 'invalid' as any;
fixture.changeDetectorRef.markForCheck();
fixture.detectChanges();
}).toThrowError(/`compareWith` must be a function\./);
});
});
});

@Component({
Expand Down Expand Up @@ -1292,3 +1329,29 @@ class PreselectedRadioWithStaticValueAndNgIf {
target: new FormControl('b'),
};
}

@Component({
template: `
<mat-radio-group [compareWith]="compareFn" [(ngModel)]="selected">
@for (item of items; track item.id) {
<mat-radio-button [value]="item">{{ item.name }}</mat-radio-button>
}
</mat-radio-group>
`,
imports: [MatRadioModule, FormsModule],
changeDetection: ChangeDetectionStrategy.Eager,
})
class RadioGroupWithCompareWith {
@ViewChild(MatRadioGroup) group!: MatRadioGroup;
@ViewChildren(MatRadioButton) buttons!: QueryList<MatRadioButton>;

items = [
{id: 1, name: 'Option 1'},
{id: 2, name: 'Option 2'},
{id: 3, name: 'Option 3'},
];

selected = {id: 2, name: 'Option 2 (different reference)'};

compareFn: any = (a: any, b: any) => (a && b ? a.id === b.id : a === b);
}
47 changes: 45 additions & 2 deletions src/material/radio/radio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,13 @@ export const MAT_RADIO_DEFAULT_OPTIONS = new InjectionToken<MatRadioDefaultOptio
},
);

/**
* Throws an exception when the compareWith input of mat-radio-group is not a function.
*/
export function getMatRadioNonFunctionValueError(): Error {
return Error('`compareWith` must be a function.');
}

/**
* A group of radio buttons. May contain one or more `<mat-radio-button>` elements.
*/
Expand Down Expand Up @@ -197,6 +204,29 @@ export class MatRadioGroup implements AfterContentInit, OnDestroy, ControlValueA
* radio button, this value persists to be applied in case a new radio button is added with a
* matching value.
*/
/** Comparison function to specify which radio button is displayed. Defaults to object equality. */
private _compareWith: (o1: any, o2: any) => boolean = (o1: any, o2: any) => o1 === o2;

/**
* Function to compare the option values with the selected values. The first argument
* is a value from an option. The second is a value from the selection. A boolean
* should be returned.
*/
@Input()
get compareWith() {
return this._compareWith;
}
set compareWith(fn: (o1: any, o2: any) => boolean) {
if (typeof fn !== 'function' && (typeof ngDevMode === 'undefined' || ngDevMode)) {
throw getMatRadioNonFunctionValueError();
}
this._compareWith = fn;
if (this._isInitialized) {
this._updateSelectedRadioFromValue();
this._checkSelectedRadioButton();
}
}

@Input()
get value(): any {
return this._value;
Expand Down Expand Up @@ -306,15 +336,28 @@ export class MatRadioGroup implements AfterContentInit, OnDestroy, ControlValueA
}
}

/** Updates the `selected` radio button from the internal _value state. */
private _checkValueEquality(a: any, b: any): boolean {
try {
return this._compareWith(a, b);
} catch (error) {
if (typeof ngDevMode === 'undefined' || ngDevMode) {
console.warn(error);
}
return false;
}
}

/** Updates the `selected` radio button from the internal _value state. */
private _updateSelectedRadioFromValue(): void {
// If the value already matches the selected radio, do nothing.
const isAlreadySelected = this._selected !== null && this._selected.value === this._value;
const isAlreadySelected =
this._selected !== null && this._checkValueEquality(this._selected.value, this._value);

if (this._radios && !isAlreadySelected) {
this._selected = null;
this._radios.forEach(radio => {
radio.checked = this.value === radio.value;
radio.checked = this._checkValueEquality(this.value, radio.value);
if (radio.checked) {
this._selected = radio;
}
Expand Down
Loading