diff --git a/goldens/material/checkbox/index.api.md b/goldens/material/checkbox/index.api.md index 4eb98f892047..4e5916a031af 100644 --- a/goldens/material/checkbox/index.api.md +++ b/goldens/material/checkbox/index.api.md @@ -14,6 +14,7 @@ import * as i0 from '@angular/core'; import * as i2 from '@angular/cdk/bidi'; import { InjectionToken } from '@angular/core'; import { OnChanges } from '@angular/core'; +import { OnInit } from '@angular/core'; import { SimpleChanges } from '@angular/core'; import { ValidationErrors } from '@angular/forms'; import { Validator } from '@angular/forms'; @@ -22,7 +23,7 @@ import { Validator } from '@angular/forms'; export const MAT_CHECKBOX_DEFAULT_OPTIONS: InjectionToken; // @public (undocumented) -export class MatCheckbox implements AfterViewInit, OnChanges, ControlValueAccessor, Validator, FocusableOption { +export class MatCheckbox implements OnInit, AfterViewInit, OnChanges, ControlValueAccessor, Validator, FocusableOption { constructor(); protected _animationClasses: { uncheckedToChecked: string; @@ -86,6 +87,8 @@ export class MatCheckbox implements AfterViewInit, OnChanges, ControlValueAccess // (undocumented) ngOnChanges(changes: SimpleChanges): void; // (undocumented) + ngOnInit(): void; + // (undocumented) _onBlur(): void; // (undocumented) _onInputClick(): void; diff --git a/goldens/material/slide-toggle/index.api.md b/goldens/material/slide-toggle/index.api.md index c297901a4cf5..4d148d5c5be1 100644 --- a/goldens/material/slide-toggle/index.api.md +++ b/goldens/material/slide-toggle/index.api.md @@ -16,6 +16,7 @@ import * as i2 from '@angular/cdk/bidi'; import { InjectionToken } from '@angular/core'; import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; +import { OnInit } from '@angular/core'; import { SimpleChanges } from '@angular/core'; import { ValidationErrors } from '@angular/forms'; import { Validator } from '@angular/forms'; @@ -24,7 +25,7 @@ import { Validator } from '@angular/forms'; export const MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS: InjectionToken; // @public (undocumented) -export class MatSlideToggle implements OnDestroy, AfterContentInit, OnChanges, ControlValueAccessor, Validator { +export class MatSlideToggle implements OnInit, OnDestroy, AfterContentInit, OnChanges, ControlValueAccessor, Validator { constructor(); ariaDescribedby: string; ariaLabel: string | null; @@ -78,6 +79,8 @@ export class MatSlideToggle implements OnDestroy, AfterContentInit, OnChanges, C ngOnChanges(changes: SimpleChanges): void; // (undocumented) ngOnDestroy(): void; + // (undocumented) + ngOnInit(): void; _noopAnimations: boolean; registerOnChange(fn: any): void; registerOnTouched(fn: any): void; diff --git a/src/material/checkbox/checkbox.spec.ts b/src/material/checkbox/checkbox.spec.ts index 4dc36d493e30..58004aabf167 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,55 @@ describe('MatCheckbox', () => { }); }); + describe('with signal forms', () => { + let checkboxDebugElement: DebugElement; + let checkboxInstance: MatCheckbox; + let testComponent: CheckboxWithSignalForms; + let inputElement: HTMLInputElement; + + beforeEach(() => { + fixture = TestBed.createComponent(CheckboxWithSignalForms); + fixture.detectChanges(); + + checkboxDebugElement = fixture.debugElement.query(By.directive(MatCheckbox))!; + checkboxInstance = checkboxDebugElement.componentInstance; + testComponent = fixture.debugElement.componentInstance; + inputElement = checkboxDebugElement.nativeElement.querySelector('input'); + }); + + it('should produce a single `required` error when used with signal forms', () => { + expect(checkboxInstance.checked).toBe(false); + expect(checkboxInstance.required).toBe(true); + expect(inputElement.required).toBe(true); + expect(testComponent.form().valid()).toBe(false); + expect( + testComponent + .form() + .errors() + .map(e => e.kind), + ).toEqual(['required']); + + inputElement.click(); + fixture.detectChanges(); + + expect(checkboxInstance.checked).toBe(true); + expect(testComponent.form().valid()).toBe(true); + expect(testComponent.form().errors().length).toBe(0); + + inputElement.click(); + fixture.detectChanges(); + + expect(checkboxInstance.checked).toBe(false); + expect(testComponent.form().valid()).toBe(false); + expect( + testComponent + .form() + .errors() + .map(e => e.kind), + ).toEqual(['required']); + }); + }); + describe('without label', () => { let checkboxInnerContainer: HTMLElement; @@ -1359,3 +1409,14 @@ class CheckboxWithTabindexAttr {} changeDetection: ChangeDetectionStrategy.Eager, }) class CheckboxWithStaticAriaAttributes {} + +@Component({ + template: ``, + imports: [MatCheckbox, FormField], +}) +class CheckboxWithSignalForms { + readonly isChecked = signal(false); + readonly form = form(this.isChecked, path => { + required(path); + }); +} diff --git a/src/material/checkbox/checkbox.ts b/src/material/checkbox/checkbox.ts index 103390066fb3..bae8c92182d5 100644 --- a/src/material/checkbox/checkbox.ts +++ b/src/material/checkbox/checkbox.ts @@ -26,6 +26,8 @@ import { inject, HostAttributeToken, signal, + Injector, + OnInit, } from '@angular/core'; import { AbstractControl, @@ -47,6 +49,7 @@ import { MatCheckboxDefaultOptions, } from './checkbox-config'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; +import {FORM_FIELD, FormField} from '@angular/forms/signals'; /** * Represents the different states that require custom transitions between them. @@ -106,7 +109,7 @@ export class MatCheckboxChange { imports: [MatRipple, _MatInternalFormField], }) export class MatCheckbox - implements AfterViewInit, OnChanges, ControlValueAccessor, Validator, FocusableOption + implements OnInit, AfterViewInit, OnChanges, ControlValueAccessor, Validator, FocusableOption { _elementRef = inject>(ElementRef); private _changeDetectorRef = inject(ChangeDetectorRef); @@ -115,6 +118,8 @@ export class MatCheckbox private _options = inject(MAT_CHECKBOX_DEFAULT_OPTIONS, { optional: true, }); + private readonly _injector = inject(Injector); + private _signalFormField: FormField | null = null; /** Focuses the checkbox. */ focus() { @@ -251,6 +256,10 @@ export class MatCheckbox } } + ngOnInit() { + this._signalFormField = this._injector.get(FORM_FIELD, undefined, {optional: true, self: true}); + } + ngAfterViewInit() { this._syncIndeterminate(this.indeterminate); } @@ -346,7 +355,9 @@ export class MatCheckbox // Implemented as a part of Validator. validate(control: AbstractControl): ValidationErrors | null { - return this.required && control.value !== true ? {'required': true} : null; + return this.required && control.value !== true && !this._signalFormField + ? {'required': true} + : null; } // Implemented as a part of Validator. diff --git a/src/material/slide-toggle/slide-toggle.spec.ts b/src/material/slide-toggle/slide-toggle.spec.ts index 760d2a824fa6..405cd0640390 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'; @@ -807,6 +808,54 @@ describe('MatSlideToggle with forms', () => { expect(fixture.componentInstance.onChange).toHaveBeenCalledTimes(1); }); }); + + describe('with signal forms', () => { + let fixture: ComponentFixture; + let testComponent: SlideToggleWithSignalForms; + let slideToggle: MatSlideToggle; + let buttonElement: HTMLButtonElement; + + beforeEach(() => { + fixture = TestBed.createComponent(SlideToggleWithSignalForms); + fixture.detectChanges(); + + testComponent = fixture.debugElement.componentInstance; + slideToggle = fixture.debugElement.query(By.directive(MatSlideToggle))!.componentInstance; + buttonElement = fixture.debugElement.query(By.css('button'))!.nativeElement; + }); + + it('should produce a single `required` error when used with signal forms', () => { + expect(slideToggle.checked).toBe(false); + expect(slideToggle.required).toBe(true); + expect(buttonElement.getAttribute('aria-required')).toBe('true'); + expect(testComponent.form().valid()).toBe(false); + expect( + testComponent + .form() + .errors() + .map(e => e.kind), + ).toEqual(['required']); + + buttonElement.click(); + fixture.detectChanges(); + + expect(slideToggle.checked).toBe(true); + expect(testComponent.form().valid()).toBe(true); + expect(testComponent.form().errors().length).toBe(0); + + buttonElement.click(); + fixture.detectChanges(); + + expect(slideToggle.checked).toBe(false); + expect(testComponent.form().valid()).toBe(false); + expect( + testComponent + .form() + .errors() + .map(e => e.kind), + ).toEqual(['required']); + }); + }); }); @Component({ @@ -934,3 +983,14 @@ class SlideToggleWithModelAndChangeEvent { changeDetection: ChangeDetectionStrategy.Eager, }) class SlideToggleWithStaticAriaAttributes {} + +@Component({ + template: ``, + imports: [MatSlideToggle, FormField], +}) +class SlideToggleWithSignalForms { + readonly isChecked = signal(false); + readonly form = form(this.isChecked, path => { + required(path); + }); +} diff --git a/src/material/slide-toggle/slide-toggle.ts b/src/material/slide-toggle/slide-toggle.ts index 3fb3c70f9a51..28570bfdd279 100644 --- a/src/material/slide-toggle/slide-toggle.ts +++ b/src/material/slide-toggle/slide-toggle.ts @@ -24,6 +24,8 @@ import { ViewEncapsulation, inject, HostAttributeToken, + Injector, + OnInit, } from '@angular/core'; import { AbstractControl, @@ -45,6 +47,7 @@ import { MatRipple, } from '../core'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; +import {FORM_FIELD, FormField} from '@angular/forms/signals'; /** Change event object emitted by a slide toggle. */ export class MatSlideToggleChange { @@ -91,12 +94,14 @@ export class MatSlideToggleChange { imports: [MatRipple, _MatInternalFormField], }) export class MatSlideToggle - implements OnDestroy, AfterContentInit, OnChanges, ControlValueAccessor, Validator + implements OnInit, OnDestroy, AfterContentInit, OnChanges, ControlValueAccessor, Validator { private _elementRef = inject(ElementRef); protected _focusMonitor = inject(FocusMonitor); protected _changeDetectorRef = inject(ChangeDetectorRef); defaults = inject(MAT_SLIDE_TOGGLE_DEFAULT_OPTIONS); + private readonly _injector = inject(Injector); + private _signalFormField: FormField | null = null; private _onChange = (_: any) => {}; private _onTouched = () => {}; @@ -219,6 +224,10 @@ export class MatSlideToggle this._labelId = this._uniqueId + '-label'; } + ngOnInit() { + this._signalFormField = this._injector.get(FORM_FIELD, undefined, {optional: true, self: true}); + } + ngAfterContentInit() { this._focusMonitor.monitor(this._elementRef, true).subscribe(focusOrigin => { if (focusOrigin === 'keyboard' || focusOrigin === 'program') { @@ -266,7 +275,9 @@ export class MatSlideToggle /** Implemented as a part of Validator. */ validate(control: AbstractControl): ValidationErrors | null { - return this.required && control.value !== true ? {'required': true} : null; + return this.required && control.value !== true && !this._signalFormField + ? {'required': true} + : null; } /** Implemented as a part of Validator. */