diff --git a/src/material/checkbox/checkbox.spec.ts b/src/material/checkbox/checkbox.spec.ts
index 4dc36d493e30..a3bc4c047ba3 100644
--- a/src/material/checkbox/checkbox.spec.ts
+++ b/src/material/checkbox/checkbox.spec.ts
@@ -1,7 +1,8 @@
import {dispatchFakeEvent} from '@angular/cdk/testing/private';
-import {ChangeDetectionStrategy, Component, DebugElement} from '@angular/core';
+import {ChangeDetectionStrategy, Component, DebugElement, signal} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms';
+import {FormField, form, required} from '@angular/forms/signals';
import {ThemePalette} from '../core';
import {By} from '@angular/platform-browser';
import {
@@ -1105,6 +1106,29 @@ describe('MatCheckbox', () => {
});
});
+ describe('with signal forms', () => {
+ it('should only report the required error from the schema', () => {
+ const fixture = TestBed.createComponent(CheckboxWithSignalForms);
+ fixture.detectChanges();
+
+ const errors = fixture.componentInstance.agreeForm().errors();
+ expect(errors.length).toBe(1);
+ expect(errors[0].kind).toBe('required');
+ expect(errors[0].message).toBe('You must agree');
+ });
+
+ it('should clear the required error when checked', () => {
+ const fixture = TestBed.createComponent(CheckboxWithSignalForms);
+ fixture.detectChanges();
+
+ fixture.nativeElement.querySelector('input').click();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.agreeForm().value()).toBe(true);
+ expect(fixture.componentInstance.agreeForm().errors()).toEqual([]);
+ });
+ });
+
describe('without label', () => {
let checkboxInnerContainer: HTMLElement;
@@ -1359,3 +1383,11 @@ class CheckboxWithTabindexAttr {}
changeDetection: ChangeDetectionStrategy.Eager,
})
class CheckboxWithStaticAriaAttributes {}
+
+@Component({
+ template: `Agree`,
+ imports: [MatCheckbox, FormField],
+})
+class CheckboxWithSignalForms {
+ agreeForm = form(signal(false), path => required(path, {message: 'You must agree'}));
+}
diff --git a/src/material/checkbox/checkbox.ts b/src/material/checkbox/checkbox.ts
index 103390066fb3..abc7becfe584 100644
--- a/src/material/checkbox/checkbox.ts
+++ b/src/material/checkbox/checkbox.ts
@@ -26,6 +26,7 @@ import {
inject,
HostAttributeToken,
signal,
+ Injector,
} from '@angular/core';
import {
AbstractControl,
@@ -35,6 +36,7 @@ import {
ValidationErrors,
Validator,
} from '@angular/forms';
+import {FORM_FIELD} from '@angular/forms/signals';
import {
MatRipple,
_MatInternalFormField,
@@ -111,6 +113,7 @@ export class MatCheckbox
_elementRef = inject>(ElementRef);
private _changeDetectorRef = inject(ChangeDetectorRef);
private _ngZone = inject(NgZone);
+ private _injector = inject(Injector);
protected _animationsDisabled = _animationsDisabled();
private _options = inject(MAT_CHECKBOX_DEFAULT_OPTIONS, {
optional: true,
@@ -346,6 +349,14 @@ export class MatCheckbox
// Implemented as a part of Validator.
validate(control: AbstractControl): ValidationErrors | null {
+ // Signal forms validate `required` through the schema and set the `required` input from it,
+ // so reporting the error here as well would add a duplicate without the schema's message.
+ // Note that the field is looked up lazily, because injecting it eagerly results in a circular
+ // dependency (the field injects the component as its value accessor).
+ if (this._injector.get(FORM_FIELD, null, {optional: true, self: true})) {
+ return null;
+ }
+
return this.required && control.value !== true ? {'required': true} : null;
}
diff --git a/src/material/slide-toggle/slide-toggle.spec.ts b/src/material/slide-toggle/slide-toggle.spec.ts
index 760d2a824fa6..5e82a11c7fe9 100644
--- a/src/material/slide-toggle/slide-toggle.spec.ts
+++ b/src/material/slide-toggle/slide-toggle.spec.ts
@@ -1,9 +1,10 @@
import {FocusMonitor} from '@angular/cdk/a11y';
import {BidiModule, Direction} from '@angular/cdk/bidi';
import {dispatchFakeEvent} from '@angular/cdk/testing/private';
-import {Component, ChangeDetectionStrategy} from '@angular/core';
+import {Component, ChangeDetectionStrategy, signal} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {FormControl, FormsModule, NgModel, ReactiveFormsModule} from '@angular/forms';
+import {FormField, form, required} from '@angular/forms/signals';
import {By} from '@angular/platform-browser';
import {MatSlideToggle, MatSlideToggleChange, MatSlideToggleModule} from './index';
import {MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS} from './slide-toggle-config';
@@ -728,6 +729,29 @@ describe('MatSlideToggle with forms', () => {
});
});
+ describe('with signal forms', () => {
+ it('should only report the required error from the schema', () => {
+ const fixture = TestBed.createComponent(SlideToggleWithSignalForms);
+ fixture.detectChanges();
+
+ const errors = fixture.componentInstance.agreeForm().errors();
+ expect(errors.length).toBe(1);
+ expect(errors[0].kind).toBe('required');
+ expect(errors[0].message).toBe('You must agree');
+ });
+
+ it('should clear the required error when checked', () => {
+ const fixture = TestBed.createComponent(SlideToggleWithSignalForms);
+ fixture.detectChanges();
+
+ fixture.nativeElement.querySelector('button').click();
+ fixture.detectChanges();
+
+ expect(fixture.componentInstance.agreeForm().value()).toBe(true);
+ expect(fixture.componentInstance.agreeForm().errors()).toEqual([]);
+ });
+ });
+
describe('with form element', () => {
let fixture: ComponentFixture;
let testComponent: SlideToggleWithForm;
@@ -934,3 +958,11 @@ class SlideToggleWithModelAndChangeEvent {
changeDetection: ChangeDetectionStrategy.Eager,
})
class SlideToggleWithStaticAriaAttributes {}
+
+@Component({
+ template: `Agree`,
+ imports: [MatSlideToggle, FormField],
+})
+class SlideToggleWithSignalForms {
+ agreeForm = form(signal(false), path => required(path, {message: 'You must agree'}));
+}
diff --git a/src/material/slide-toggle/slide-toggle.ts b/src/material/slide-toggle/slide-toggle.ts
index 3fb3c70f9a51..019cfd61ee00 100644
--- a/src/material/slide-toggle/slide-toggle.ts
+++ b/src/material/slide-toggle/slide-toggle.ts
@@ -24,6 +24,7 @@ import {
ViewEncapsulation,
inject,
HostAttributeToken,
+ Injector,
} from '@angular/core';
import {
AbstractControl,
@@ -33,6 +34,7 @@ import {
ValidationErrors,
Validator,
} from '@angular/forms';
+import {FORM_FIELD} from '@angular/forms/signals';
import {_IdGenerator, FocusMonitor} from '@angular/cdk/a11y';
import {
MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS,
@@ -96,6 +98,7 @@ export class MatSlideToggle
private _elementRef = inject(ElementRef);
protected _focusMonitor = inject(FocusMonitor);
protected _changeDetectorRef = inject(ChangeDetectorRef);
+ private _injector = inject(Injector);
defaults = inject(MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS);
private _onChange = (_: any) => {};
@@ -266,6 +269,14 @@ export class MatSlideToggle
/** Implemented as a part of Validator. */
validate(control: AbstractControl): ValidationErrors | null {
+ // Signal forms validate `required` through the schema and set the `required` input from it,
+ // so reporting the error here as well would add a duplicate without the schema's message.
+ // Note that the field is looked up lazily, because injecting it eagerly results in a circular
+ // dependency (the field injects the component as its value accessor).
+ if (this._injector.get(FORM_FIELD, null, {optional: true, self: true})) {
+ return null;
+ }
+
return this.required && control.value !== true ? {'required': true} : null;
}