From 4be0ab4706997f567d0ea524946ebb0d7968184e Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 22 Sep 2026 10:52:50 +0200 Subject: [PATCH 1/2] fix(material/form-field): support signal forms in MatFormFieldControl Updates `MatFormFieldControl` to allow an `ngField` property for those directives that support signal forms. It also makes the following changes: * Removes `value` since it isn't used anywhere and it can prevent apps from implementing `FormValueControl`. * Makes `stateChanges` optional since it shouldn't be necessary for signal-based components. * Allows `focused` to be a signal. * Allows `empty` to be a signal. * Allows `shouldLabelFloat` to be a signal. * Allows `required` to be a signal. * Allows `disabled` to be a signal. * Allows `errorState` to be a signal. * Allows `autofilled` to be a signal. * Allows `userAriaDescribedBy` to be a signal. --- goldens/material/chips/index.api.md | 1 + goldens/material/datepicker/index.api.md | 1 + goldens/material/form-field/index.api.md | 32 ++++--- goldens/material/input/index.api.md | 9 +- goldens/material/paginator/index.api.md | 1 + goldens/material/select/index.api.md | 9 +- .../creating-a-custom-form-field-control.md | 70 +++++++------- .../autocomplete/autocomplete-trigger.ts | 11 ++- src/material/form-field/form-field-control.ts | 46 ++++----- src/material/form-field/form-field.html | 10 +- src/material/form-field/form-field.ts | 94 ++++++++++++++++--- 11 files changed, 187 insertions(+), 97 deletions(-) diff --git a/goldens/material/chips/index.api.md b/goldens/material/chips/index.api.md index 8177083d6078..5225a9fd59ae 100644 --- a/goldens/material/chips/index.api.md +++ b/goldens/material/chips/index.api.md @@ -29,6 +29,7 @@ import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { OnInit } from '@angular/core'; import { QueryList } from '@angular/core'; +import { Signal } from '@angular/core'; import { Subject } from 'rxjs'; // @public diff --git a/goldens/material/datepicker/index.api.md b/goldens/material/datepicker/index.api.md index 5437928453b3..5ba1797bad21 100644 --- a/goldens/material/datepicker/index.api.md +++ b/goldens/material/datepicker/index.api.md @@ -35,6 +35,7 @@ import { OnDestroy } from '@angular/core'; import { OnInit } from '@angular/core'; import { Portal } from '@angular/cdk/portal'; import { ScrollStrategy } from '@angular/cdk/overlay'; +import { Signal } from '@angular/core'; import { SimpleChanges } from '@angular/core'; import { Subject } from 'rxjs'; import { TemplatePortal } from '@angular/cdk/portal'; diff --git a/goldens/material/form-field/index.api.md b/goldens/material/form-field/index.api.md index 275c4d2f9602..d633c3e5bf53 100644 --- a/goldens/material/form-field/index.api.md +++ b/goldens/material/form-field/index.api.md @@ -10,6 +10,7 @@ import { AfterContentInit } from '@angular/core'; import { AfterViewInit } from '@angular/core'; import { BooleanInput } from '@angular/cdk/coercion'; import { ElementRef } from '@angular/core'; +import { Field } from '@angular/forms/signals'; import * as i0 from '@angular/core'; import * as i1 from '@angular/cdk/observers'; import * as i2 from '@angular/cdk/bidi'; @@ -18,6 +19,7 @@ import { NgControl } from '@angular/forms'; import { Observable } from 'rxjs'; import { OnDestroy } from '@angular/core'; import { QueryList } from '@angular/core'; +import { Signal } from '@angular/core'; // @public export type FloatLabelType = 'always' | 'auto'; @@ -79,11 +81,11 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte // (undocumented) _formFieldControl: MatFormFieldControl_2; getConnectedOverlayOrigin(): ElementRef; - getLabelId: i0.Signal; + getLabelId: Signal; _getSubscriptMessageType(): 'error' | 'hint'; _handleLabelResized(): void; // (undocumented) - _hasFloatingLabel: i0.Signal; + _hasFloatingLabel: Signal; // (undocumented) _hasIconPrefix: boolean; // (undocumented) @@ -124,7 +126,7 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte _prefixChildren: QueryList; _refreshOutlineNotchWidth(): void; _shouldAlwaysFloat(): boolean; - _shouldForward(prop: keyof AbstractControlDirective): boolean; + _shouldForward(prop: 'valid' | 'dirty' | 'touched' | 'pending' | 'untouched' | 'pristine' | 'invalid'): boolean; // (undocumented) _shouldLabelFloat(): boolean; get subscriptSizing(): SubscriptSizing; @@ -138,6 +140,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte // (undocumented) _textSuffixContainer: ElementRef; // (undocumented) + protected _unwrapMaybeSignal(value: T | Signal): T; + // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; @@ -148,24 +152,24 @@ export type MatFormFieldAppearance = 'fill' | 'outline'; // @public export abstract class MatFormFieldControl { - readonly autofilled?: boolean; + readonly autofilled?: boolean | Signal; readonly controlType?: string; readonly describedByIds?: string[]; readonly disableAutomaticLabeling?: boolean; - readonly disabled: boolean; - readonly empty: boolean; - readonly errorState: boolean; - readonly focused: boolean; + readonly disabled: boolean | Signal; + readonly empty: boolean | Signal; + readonly errorState: boolean | Signal; + readonly focused: boolean | Signal; readonly id: string; readonly ngControl: NgControl | AbstractControlDirective | null; + readonly ngField?: Field | null; abstract onContainerClick(event: MouseEvent): void; - readonly placeholder: string; - readonly required: boolean; + readonly required: boolean | Signal; abstract setDescribedByIds(ids: string[]): void; - readonly shouldLabelFloat: boolean; - readonly stateChanges: Observable; - readonly userAriaDescribedBy?: string; - value: T | null; + readonly shouldLabelFloat: boolean | Signal; + readonly stateChanges?: Observable | null; + readonly userAriaDescribedBy?: string | Signal; + value?: any; // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration, never, never, {}, {}, never, never, true, never>; // (undocumented) diff --git a/goldens/material/input/index.api.md b/goldens/material/input/index.api.md index 0abf0f3f5bf4..7580c6c72192 100644 --- a/goldens/material/input/index.api.md +++ b/goldens/material/input/index.api.md @@ -26,6 +26,7 @@ import { OnChanges } from '@angular/core'; import { OnDestroy } from '@angular/core'; import { Platform } from '@angular/cdk/platform'; import { QueryList } from '@angular/core'; +import { Signal } from '@angular/core'; import { Subject } from 'rxjs'; import { WritableSignal } from '@angular/core'; @@ -73,11 +74,11 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte // (undocumented) _formFieldControl: MatFormFieldControl; getConnectedOverlayOrigin(): ElementRef; - getLabelId: i0.Signal; + getLabelId: Signal; _getSubscriptMessageType(): 'error' | 'hint'; _handleLabelResized(): void; // (undocumented) - _hasFloatingLabel: i0.Signal; + _hasFloatingLabel: Signal; // (undocumented) _hasIconPrefix: boolean; // (undocumented) @@ -118,7 +119,7 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte _prefixChildren: QueryList; _refreshOutlineNotchWidth(): void; _shouldAlwaysFloat(): boolean; - _shouldForward(prop: keyof AbstractControlDirective): boolean; + _shouldForward(prop: 'valid' | 'dirty' | 'touched' | 'pending' | 'untouched' | 'pristine' | 'invalid'): boolean; // (undocumented) _shouldLabelFloat(): boolean; get subscriptSizing(): SubscriptSizing; @@ -132,6 +133,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte // (undocumented) _textSuffixContainer: ElementRef; // (undocumented) + protected _unwrapMaybeSignal(value: T | Signal): T; + // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; diff --git a/goldens/material/paginator/index.api.md b/goldens/material/paginator/index.api.md index 6a6ccc3b1a2b..7582b573f631 100644 --- a/goldens/material/paginator/index.api.md +++ b/goldens/material/paginator/index.api.md @@ -47,6 +47,7 @@ import { OverlayRef } from '@angular/cdk/overlay'; import { QueryList } from '@angular/core'; import { ScrollStrategy } from '@angular/cdk/overlay'; import { SelectionModel } from '@angular/cdk/collections'; +import { Signal } from '@angular/core'; import { SimpleChanges } from '@angular/core'; import { Subject } from 'rxjs'; import { ViewportRuler } from '@angular/cdk/scrolling'; diff --git a/goldens/material/select/index.api.md b/goldens/material/select/index.api.md index 16a2c385582a..ddd588917d12 100644 --- a/goldens/material/select/index.api.md +++ b/goldens/material/select/index.api.md @@ -40,6 +40,7 @@ import { OnInit } from '@angular/core'; import { QueryList } from '@angular/core'; import { ScrollStrategy } from '@angular/cdk/overlay'; import { SelectionModel } from '@angular/cdk/collections'; +import { Signal } from '@angular/core'; import { SimpleChanges } from '@angular/core'; import { Subject } from 'rxjs'; import { ViewportRuler } from '@angular/cdk/scrolling'; @@ -86,11 +87,11 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte // (undocumented) _formFieldControl: MatFormFieldControl; getConnectedOverlayOrigin(): ElementRef; - getLabelId: i0.Signal; + getLabelId: Signal; _getSubscriptMessageType(): 'error' | 'hint'; _handleLabelResized(): void; // (undocumented) - _hasFloatingLabel: i0.Signal; + _hasFloatingLabel: Signal; // (undocumented) _hasIconPrefix: boolean; // (undocumented) @@ -131,7 +132,7 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte _prefixChildren: QueryList; _refreshOutlineNotchWidth(): void; _shouldAlwaysFloat(): boolean; - _shouldForward(prop: keyof AbstractControlDirective): boolean; + _shouldForward(prop: 'valid' | 'dirty' | 'touched' | 'pending' | 'untouched' | 'pristine' | 'invalid'): boolean; // (undocumented) _shouldLabelFloat(): boolean; get subscriptSizing(): SubscriptSizing; @@ -145,6 +146,8 @@ export class MatFormField implements FloatingLabelParent, AfterContentInit, Afte // (undocumented) _textSuffixContainer: ElementRef; // (undocumented) + protected _unwrapMaybeSignal(value: T | Signal): T; + // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; diff --git a/guides/creating-a-custom-form-field-control.md b/guides/creating-a-custom-form-field-control.md index 947e54f24a13..f3a6fedb970b 100644 --- a/guides/creating-a-custom-form-field-control.md +++ b/guides/creating-a-custom-form-field-control.md @@ -63,7 +63,9 @@ export class MyTelInput { } constructor(fb: FormBuilder) { - this.parts = fb.group({ + const formBuilder = inject(FormBuilder); + + this.parts = formBuilder.group({ 'area': '', 'exchange': '', 'subscriber': '', @@ -158,31 +160,35 @@ set placeholder(plh) { private _placeholder: string; ``` +#### `ngField` + +If your control is designed to work with Signal Forms (`@angular/forms/signals`) rather than +reactive or template-driven forms, you can expose the `ngField` property instead of +`ngControl`: + +```ts +readonly ngField = inject(FORM_FIELD, {optional: true, self: true}); +``` + +`` will use `ngField` to read the signal form field's state (e.g. `valid` or `dirty`) +and automatically synchronize the corresponding CSS classes on the `` host. + + #### `ngControl` This property allows the form field control to specify the `@angular/forms` control that is bound to this component. Since we haven't set up our component to act as a `ControlValueAccessor`, we'll -just set this to `null` in our component. +just set this to `null` in our component. (If your control works with Signal Forms, see +[`ngField`](#ngField) instead). ```ts -ngControl: NgControl = null; +ngControl = inject(NgControl, {optional: true, self: true}); ``` It is likely you will want to implement `ControlValueAccessor` so that your component can work with `formControl` and `ngModel`. If you do implement `ControlValueAccessor` you will need to get a reference to the `NgControl` associated with your control and make it publicly available. -The easy way is to add it as a public property to your constructor and let dependency injection -handle it: - -```ts -constructor( - ..., - @Optional() @Self() public ngControl: NgControl, - ..., -) { } -``` - Note that if your component implements `ControlValueAccessor`, it may already be set up to provide `NG_VALUE_ACCESSOR` (in the `providers` part of the component's decorator, or possibly in a module declaration). If so, you may get a *cannot instantiate cyclic dependency* error. @@ -203,12 +209,11 @@ To resolve this, remove the `NG_VALUE_ACCESSOR` provider and instead set the val ], }) export class MyTelInput implements MatFormFieldControl, ControlValueAccessor { - constructor( - ..., - @Optional() @Self() public ngControl: NgControl, - ..., - ) { + ... + ngControl = inject(NgControl, {optional: true, self: true}); + ... + constructor() { // Replace the provider from above with this. if (this.ngControl != null) { // Setting the value accessor directly (instead of using @@ -221,7 +226,6 @@ export class MyTelInput implements MatFormFieldControl, ControlValueAcces For additional information about `ControlValueAccessor` see the [API docs](https://angular.dev/api/forms/ControlValueAccessor). - #### `focused` This property indicates whether the form field control should be considered to be in a @@ -326,8 +330,8 @@ private _disabled = false; #### `errorState` -This property indicates whether the associated `NgControl` is in an error state. For example, -we can show an error if the input is invalid and our component has been touched. +This property indicates whether the associated `NgControl` or `ngField` is in an error +state. For example, we can show an error if the input is invalid and our component has been touched. ```ts get errorState(): boolean { @@ -341,14 +345,11 @@ to handle such cases we should re-evaluate `errorState` on every change detectio ```ts /** Whether the component is in an error state. */ errorState: boolean = false; - -constructor( - ..., - @Optional() private _parentForm: NgForm, - @Optional() private _parentFormGroup: FormGroupDirective -) { ... -} +// These are only relevant for non-signal forms. +private _parentForm = inject(NgForm, {optional: true}); +private _parentFormGroup = inject(FormGroupDirective, {optional: true}); +... ngDoCheck() { if (this.ngControl) { @@ -388,7 +389,7 @@ controlType = 'example-tel-input'; This method is used by the `` to set element ids that should be used for the `aria-describedby` attribute of your control. The ids are controlled through the form field as hints or errors are conditionally displayed and should be reflected in the control's -`aria-describedby` attribute for an improved accessibility experience. +`aria-describedby` attribute for an improved accessibility experience. The `setDescribedByIds` method is invoked whenever the control's state changes. Custom controls need to implement this method and update the `aria-describedby` attribute based on the specified @@ -450,9 +451,8 @@ to the label element id provided by the parent ``. ```typescript export class MyTelInput implements MatFormFieldControl { ... - - constructor(... - @Optional() public parentFormField: MatFormField) { + parentFormField = inject(MatFormField, {optional: true}); + ... ``` ```html @@ -476,8 +476,8 @@ do is place it inside a `` ``` We also get all the features that come with `` such as floating placeholder, -prefix, suffix, hints, and errors (if we've given the form field an `NgControl` and correctly report -the error state). +prefix, suffix, hints, and errors (if we've given the form field an `NgControl` or `ngField` +and correctly report the error state). ```html diff --git a/src/material/autocomplete/autocomplete-trigger.ts b/src/material/autocomplete/autocomplete-trigger.ts index 5e9ad873463f..5c7bd0d39b2f 100644 --- a/src/material/autocomplete/autocomplete-trigger.ts +++ b/src/material/autocomplete/autocomplete-trigger.ts @@ -42,6 +42,7 @@ import { booleanAttribute, forwardRef, inject, + isWritableSignal, } from '@angular/core'; import {coerceArray} from '@angular/cdk/coercion'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; @@ -694,8 +695,14 @@ export class MatAutocompleteTrigger private _updateNativeInputValue(value: string): void { // If it's used within a `MatFormField`, we should set it through the property so it can go // through change detection. - if (this._formField) { - this._formField._control.value = value; + const control = this._formField?._control; + + if (control) { + if (isWritableSignal(control.value)) { + control.value.set(value); + } else { + control.value = value; + } } else { this._element.nativeElement.value = value; } diff --git a/src/material/form-field/form-field-control.ts b/src/material/form-field/form-field-control.ts index 4454c7f534f7..1fe98209e131 100644 --- a/src/material/form-field/form-field-control.ts +++ b/src/material/form-field/form-field-control.ts @@ -8,46 +8,41 @@ import {Observable} from 'rxjs'; import {AbstractControlDirective, NgControl} from '@angular/forms'; -import {Directive} from '@angular/core'; +import {Directive, Signal} from '@angular/core'; +import {Field} from '@angular/forms/signals'; /** An interface which allows a control to work inside of a `MatFormField`. */ @Directive() export abstract class MatFormFieldControl { - /** The value of the control. */ - value: T | null = null; - - /** - * Stream that emits whenever the state of the control changes such that the parent `MatFormField` - * needs to run change detection. - */ - readonly stateChanges!: Observable; - /** The element ID for this control. */ readonly id!: string; - /** The placeholder for this control. */ - readonly placeholder!: string; + /** Control if the directive supports signal forms. */ + readonly ngField?: Field | null = null; - /** Gets the AbstractControlDirective for this control. */ + /** + * Form control if the directive only supports Reactive or Template-driven forms. + * Can be skipped if your directive already sets `ngField`. + */ readonly ngControl: NgControl | AbstractControlDirective | null = null; /** Whether the control is focused. */ - readonly focused: boolean = false; + readonly focused: boolean | Signal = false; /** Whether the control is empty. */ - readonly empty: boolean = false; + readonly empty: boolean | Signal = false; /** Whether the `MatFormField` label should try to float. */ - readonly shouldLabelFloat: boolean = false; + readonly shouldLabelFloat: boolean | Signal = false; /** Whether the control is required. */ - readonly required: boolean = false; + readonly required: boolean | Signal = false; /** Whether the control is disabled. */ - readonly disabled: boolean = false; + readonly disabled: boolean | Signal = false; /** Whether the control is in an error state. */ - readonly errorState: boolean = false; + readonly errorState: boolean | Signal = false; /** * An optional name for the control type that can be used to distinguish `mat-form-field` elements @@ -60,13 +55,13 @@ export abstract class MatFormFieldControl { * Whether the input is currently in an autofilled state. If property is not present on the * control it is assumed to be false. */ - readonly autofilled?: boolean; + readonly autofilled?: boolean | Signal; /** * Value of `aria-describedby` that should be merged with the described-by ids * which are set by the form-field. */ - readonly userAriaDescribedBy?: string; + readonly userAriaDescribedBy?: string | Signal; /** * Whether to automatically assign the ID of the form field as the `for` attribute @@ -78,9 +73,18 @@ export abstract class MatFormFieldControl { /** Gets the list of element IDs that currently describe this control. */ readonly describedByIds?: string[]; + /** + * Stream that emits whenever the state of the control changes such that the parent `MatFormField` + * needs to run change detection. Not necessary if the control is signal-based. + */ + readonly stateChanges?: Observable | null = null; + /** Sets the list of element IDs that currently describe this control. */ abstract setDescribedByIds(ids: string[]): void; /** Handles a click on the control's container. */ abstract onContainerClick(event: MouseEvent): void; + + /** Value of the form control. Left in for backwards compatibility. */ + value?: any; } diff --git a/src/material/form-field/form-field.html b/src/material/form-field/form-field.html index b5e38afad4fb..969dfcda7339 100644 --- a/src/material/form-field/form-field.html +++ b/src/material/form-field/form-field.html @@ -25,7 +25,7 @@ apps want to override it and to be able to set `aria-hidden` so that screen readers don't pick it up. --> - @if (!hideRequiredMarker && _control.required) { + @if (!hideRequiredMarker && _unwrapMaybeSignal(_control.required)) {