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; }