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..d7c5f3019835 100644 --- a/guides/creating-a-custom-form-field-control.md +++ b/guides/creating-a-custom-form-field-control.md @@ -17,20 +17,21 @@ just a starting point for us to learn.) ```ts class MyTel { - constructor(public area: string, public exchange: string, public subscriber: string) {} + constructor(readonly area: string, readonly exchange: string, readonly subscriber: string) {} } @Component({ selector: 'example-tel-input', template: ` -
- +
+ – - + – - +
`, + imports: [FormField], styles: [` div { display: flex; @@ -46,27 +47,49 @@ class MyTel { } `], }) -export class MyTelInput { - parts: FormGroup; - - @Input() - get value(): MyTel | null { - let n = this.parts.value; - if (n.area.length == 3 && n.exchange.length == 3 && n.subscriber.length == 4) { - return new MyTel(n.area, n.exchange, n.subscriber); - } - return null; - } - set value(tel: MyTel | null) { - tel = tel || new MyTel('', '', ''); - this.parts.setValue({area: tel.area, exchange: tel.exchange, subscriber: tel.subscriber}); - } +export class MyTelInput implements FormValueControl { + readonly partsModel = signal({ + area: '', + exchange: '', + subscriber: '', + }); + + readonly parts = form(this.partsModel, schemaPath => { + required(schemaPath.area); + minLength(schemaPath.area, 3); + maxLength(schemaPath.area, 3); + required(schemaPath.exchange); + minLength(schemaPath.exchange, 3); + maxLength(schemaPath.exchange, 3); + required(schemaPath.subscriber); + minLength(schemaPath.subscriber, 4); + maxLength(schemaPath.subscriber, 4); + }); + + readonly value = model(null); + + constructor() { + effect(() => { + const {area, exchange, subscriber} = this.partsModel(); + this.value.set(this.parts().valid() ? new MyTel(area, exchange, subscriber) : null); + }); - constructor(fb: FormBuilder) { - this.parts = fb.group({ - 'area': '', - 'exchange': '', - 'subscriber': '', + effect(() => { + const value = this.value() || new MyTel('', '', ''); + untracked(() => { + const current = this.partsModel(); + if ( + current.area !== value.area || + current.exchange !== value.exchange || + current.subscriber !== value.subscriber + ) { + this.partsModel.set({ + area: value.area, + exchange: value.exchange, + subscriber: value.subscriber, + }); + } + }); }); } } @@ -86,7 +109,7 @@ a provider to our component so that the form field will be able to inject it as ... providers: [{provide: MatFormFieldControl, useExisting: MyTelInput}], }) -export class MyTelInput implements MatFormFieldControl { +export class MyTelInput implements FormValueControl, MatFormFieldControl { ... } ``` @@ -98,28 +121,21 @@ the `MatFormFieldControl` interface, see the ### Implementing the methods and properties of MatFormFieldControl -#### `value` - -This property allows someone to set or get the value of our control. Its type should be the same -type we used for the type parameter when we implemented `MatFormFieldControl`. Since our component -already has a value property, we don't need to do anything for this one. - #### `stateChanges` -Because the `` uses the `OnPush` change detection strategy, we need to let it know +Because the `` uses the `OnPush` change detection strategy, it needs to know when something happens in the form field control that may require the form field to run change -detection. We do this via the `stateChanges` property. So far the only thing the form field needs to -know about is when the value changes. We'll need to emit on the stateChanges stream when that -happens, and as we continue flushing out these properties we'll likely find more places we need to -emit. We should also make sure to complete `stateChanges` when our component is destroyed. +detection. -```ts -stateChanges = new Subject(); +Note that `stateChanges` is **optional** and not necessary if your control's properties are +implemented as signals (such as `focused`, `empty`, `required`, `disabled`, and `errorState`). +Since `` reads those signals directly, it automatically reacts to their changes. -set value(tel: MyTel | null) { - ... - this.stateChanges.next(); -} +If your control uses plain, non-signal properties that change over time, you can emit on the +`stateChanges` stream: + +```ts +readonly stateChanges = new Subject(); ngOnDestroy() { this.stateChanges.complete(); @@ -135,54 +151,61 @@ element and just generate a unique ID for it. ```ts static nextId = 0; -@HostBinding() id = `example-tel-input-${MyTelInput.nextId++}`; +readonly id = `example-tel-input-${MyTelInput.nextId++}`; +``` +```ts +@Component({ + ... + host: { + '[id]': 'id', + }, +}) ``` #### `placeholder` -This property allows us to tell the `` what to use as a placeholder. In this -example, we'll do the same thing as `matInput` and `` and allow the user to specify it -via an `@Input()`. Since the value of the placeholder may change over time, we need to make sure to -trigger change detection in the parent form field by emitting on the `stateChanges` stream when the -placeholder changes. +If your control accepts a placeholder, you can expose it as a signal input: ```ts -@Input() -get placeholder() { - return this._placeholder; -} -set placeholder(plh) { - this._placeholder = plh; - this.stateChanges.next(); +readonly placeholder = input(''); +``` + +#### `ngField` + +When your control is built to work with Signal Forms (`@angular/forms/signals`), this property +exposes the bound `Field` to the parent ``: + +```ts +protected readonly _formFieldControl = inject(FORM_FIELD, {optional: true, self: true}); + +get ngField(): Field | null { + return (this._formFieldControl?.field() as Field) ?? null; } -private _placeholder: string; ``` +`` will use `ngField` to read the signal form field's state (such as `valid`, `dirty`, `touched`, and `pending`) +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. +to this component. When your control is built for Signal Forms, you can set this to `null`: ```ts -ngControl: NgControl = null; +readonly ngControl = null; ``` -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: +If you also want your component to support traditional Reactive or Template-driven forms (`formControl` and `ngModel`), you can implement `ControlValueAccessor` and inject `NgControl`: ```ts -constructor( - ..., - @Optional() @Self() public ngControl: NgControl, - ..., -) { } +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. + 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 +226,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,35 +243,27 @@ 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 focused state. When it is in a focused state, the form field is displayed with a solid color underline. For the purposes of our component, we want to consider it focused if any of the part -inputs are focused. We can use the `focusin` and `focusout` events to easily check this. We also -need to remember to emit on the `stateChanges` when the focused stated changes stream so change -detection can happen. +inputs are focused. We can use the `focusin` and `focusout` events to easily check this. -In addition to updating the focused state, we use the `focusin` and `focusout` methods to update the -internal touched state of our component, which we'll use to determine the error state. +`focused` can be declared as either a `boolean` or a `Signal`. When implemented as a signal, +the form field automatically reacts to focus changes without needing to emit on `stateChanges`: ```ts -focused = false; +readonly focused = signal(false); -onFocusIn(event: FocusEvent) { - if (!this.focused) { - this.focused = true; - this.stateChanges.next(); - } +onFocusIn() { + this.focused.set(true); } onFocusOut(event: FocusEvent) { if (!this._elementRef.nativeElement.contains(event.relatedTarget as Element)) { - this.touched = true; - this.focused = false; - this.onTouched(); - this.stateChanges.next(); + this._touched.set(true); + this.focused.set(false); } } ``` @@ -259,11 +273,13 @@ onFocusOut(event: FocusEvent) { This property indicates whether the form field control is empty. For our control, we'll consider it empty if all the parts are empty. +`empty` can be declared as either a `boolean` or a `Signal`: + ```ts -get empty() { - let n = this.parts.value; - return !n.area && !n.exchange && !n.subscriber; -} +readonly empty = computed(() => { + const {area, exchange, subscriber} = this.partsModel(); + return !area && !exchange && !subscriber; +}); ``` #### `shouldLabelFloat` @@ -273,18 +289,32 @@ use the same logic as `matInput` and float the placeholder when the input is foc Since the placeholder will be overlapping our control when it's not floating, we should hide the `–` characters when it's not floating. +`shouldLabelFloat` can be declared as either a `boolean` or a `Signal`: + ```ts -@HostBinding('class.floating') -get shouldLabelFloat() { - return this.focused || !this.empty; -} +readonly shouldLabelFloat = computed(() => { + const focused = this.focused(); + const empty = this.empty(); + return focused || !empty; +}); +``` + +We can apply a class to the host element when the label should float: + +```ts +@Component({ + ... + host: { + '[class.example-floating]': 'shouldLabelFloat()', + }, +}) ``` ```css -span { +.example-tel-input-spacer { opacity: 0; transition: opacity 200ms; } -:host.floating span { +:host.example-floating .example-tel-input-spacer { opacity: 1; } ``` @@ -292,63 +322,57 @@ span { #### `required` This property is used to indicate whether the input is required. `` uses this -information to add a required indicator to the placeholder. Again, we'll want to make sure we run -change detection if the required state changes. +information to add a required indicator to the placeholder. + +`required` can be declared as either a `boolean` or a `Signal`: ```ts -@Input() -get required() { - return this._required; -} -set required(req: BooleanInput) { - this._required = coerceBooleanProperty(req); - this.stateChanges.next(); -} -private _required = false; +readonly required = input(false, { + transform: booleanAttribute, +}); ``` #### `disabled` -This property tells the form field when it should be in the disabled state. In addition to reporting -the right state to the form field, we need to set the disabled state on the individual inputs that -make up our component. +This property tells the form field when it should be in the disabled state. + +`disabled` can be declared as either a `boolean` or a `Signal`: ```ts -@Input() -get disabled(): boolean { return this._disabled; } -set disabled(value: BooleanInput) { - this._disabled = coerceBooleanProperty(value); - this._disabled ? this.parts.disable() : this.parts.enable(); - this.stateChanges.next(); -} -private _disabled = false; +readonly disabled = input(false, { + transform: booleanAttribute, +}); ``` #### `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 `ngField` or `NgControl` is in an error +state. For example, we can show an error if our component has been touched and its internal form is invalid. + +`errorState` can be declared as either a `boolean` or a `Signal`. When implemented as a +signal, `` automatically tracks its value without requiring manual change detection: ```ts -get errorState(): boolean { - return this.parts.invalid && this.touched; -} +private readonly _touched = signal(false); + +readonly errorState = computed(() => { + const partsValid = this.parts().valid(); + const touched = this._touched(); + return !partsValid && touched; +}); ``` -However, there are some error triggers that we can't subscribe to (e.g. parent form submissions), -to handle such cases we should re-evaluate `errorState` on every change detection cycle. +For non-signal components, you can alternatively maintain a boolean `errorState` property and +re-evaluate it during `ngDoCheck()`: ```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) { @@ -364,7 +388,7 @@ private updateErrorState() { if (this.errorState !== newState) { this.errorState = newState; - this.stateChanges.next(); // Notify listeners of state changes. + this.stateChanges?.next(); } } ``` @@ -383,12 +407,21 @@ class `mat-form-field-type-example-tel-input`. controlType = 'example-tel-input'; ``` +#### `autofilled` + +This optional property indicates whether the control is currently autofilled by the browser. +Like the other properties, `autofilled` can be declared as either a `boolean` or a `Signal`: + +```ts +readonly autofilled = signal(false); +``` + #### `setDescribedByIds(ids: string[])` 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 @@ -396,11 +429,11 @@ element ids. Below is an example that shows how this can be achieved. Note that the method by default will not respect element ids that have been set manually on the control element through the `aria-describedby` attribute. To ensure that your control does not -accidentally override existing element ids specified by consumers of your control, create an -input called `userAriaDescribedby` like followed: +accidentally override existing element ids specified by consumers of your control, create a +`userAriaDescribedBy` property (which can be a `string` or `Signal`): ```ts -@Input('aria-describedby') userAriaDescribedBy: string; +readonly userAriaDescribedBy = input('', {alias: 'aria-describedby'}); ``` The form field will then pick up the user specified `aria-describedby` ids and merge @@ -414,17 +447,25 @@ setDescribedByIds(ids: string[]) { } ``` -#### `onContainerClick(event: MouseEvent)` +#### `onContainerClick()` This method will be called when the form field is clicked on. It allows your component to hook in -and handle that click however it wants. The method has one parameter, the `MouseEvent` for the -click. In our case we'll just focus the first `` if the user isn't about to click an -`` anyways. +and handle that click however it wants. In our case we'll focus the first invalid `` +(or the first input if all parts are empty): ```ts -onContainerClick(event: MouseEvent) { - if ((event.target as Element).tagName.toLowerCase() != 'input') { - this._elementRef.nativeElement.querySelector('input').focus(); +protected readonly _areaInput = viewChild.required>('area'); +protected readonly _exchangeInput = viewChild.required>('exchange'); +protected readonly _subscriberInput = + viewChild.required>('subscriber'); + +onContainerClick() { + if (this.parts.subscriber().valid() || this.parts.exchange().valid()) { + this._subscriberInput().nativeElement.focus(); + } else if (this.parts.area().valid()) { + this._exchangeInput().nativeElement.focus(); + } else { + this._areaInput().nativeElement.focus(); } } ``` @@ -448,41 +489,41 @@ In our concrete example, we add an attribute binding for `aria-labelledby` and b to the label element id provided by the parent ``. ```typescript -export class MyTelInput implements MatFormFieldControl { +export class MyTelInput implements FormValueControl, MatFormFieldControl { ... - - constructor(... - @Optional() public parentFormField: MatFormField) { + protected readonly _formField = inject(MAT_FORM_FIELD, {optional: true}); + ... +} ``` ```html @Component({ selector: 'example-tel-input', template: ` -
+
``` ### Trying it out Now that we've fully implemented the interface, we're ready to try our component out! All we need to -do is place it inside a `` +do is place it inside a `` and bind it to a signal form field: ```html - + ``` 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 `ngField` or `NgControl` +and correctly report the error state). ```html - - phone + + phone Include area code ``` diff --git a/src/components-examples/material/form-field/form-field-custom-control/example-tel-input-example.html b/src/components-examples/material/form-field/form-field-custom-control/example-tel-input-example.html index 79775ce567d5..8577b1ba1e53 100644 --- a/src/components-examples/material/form-field/form-field-custom-control/example-tel-input-example.html +++ b/src/components-examples/material/form-field/form-field-custom-control/example-tel-input-example.html @@ -1,40 +1,36 @@
– –
diff --git a/src/components-examples/material/form-field/form-field-custom-control/form-field-custom-control-example.html b/src/components-examples/material/form-field/form-field-custom-control/form-field-custom-control-example.html index 450f8afae244..e3bc9bd5efd7 100644 --- a/src/components-examples/material/form-field/form-field-custom-control/form-field-custom-control-example.html +++ b/src/components-examples/material/form-field/form-field-custom-control/form-field-custom-control-example.html @@ -1,9 +1,7 @@ -
- - Phone number - - phone - Include area code - -

Entered value: {{form.valueChanges | async | json}}

-
+ + Phone number + + phone + Include area code + +

Entered value: {{form.tel().value() | json}}

diff --git a/src/components-examples/material/form-field/form-field-custom-control/form-field-custom-control-example.ts b/src/components-examples/material/form-field/form-field-custom-control/form-field-custom-control-example.ts index d68e5c8a4dbc..83c83cc8e64f 100644 --- a/src/components-examples/material/form-field/form-field-custom-control/form-field-custom-control-example.ts +++ b/src/components-examples/material/form-field/form-field-custom-control/form-field-custom-control-example.ts @@ -1,9 +1,7 @@ -import {FocusMonitor} from '@angular/cdk/a11y'; -import {AsyncPipe, JsonPipe} from '@angular/common'; +import {JsonPipe} from '@angular/common'; import { Component, ElementRef, - OnDestroy, booleanAttribute, computed, effect, @@ -15,52 +13,55 @@ import { untracked, viewChild, } from '@angular/core'; -import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; import { - AbstractControl, - ControlValueAccessor, - FormBuilder, - FormControl, - FormGroup, - FormsModule, - NgControl, - ReactiveFormsModule, - Validators, -} from '@angular/forms'; + Field, + FORM_FIELD, + form, + FormField, + maxLength, + minLength, + required, + FormValueControl, +} from '@angular/forms/signals'; import { MAT_FORM_FIELD, MatFormFieldControl, - MatFormFieldModule, + MatFormField, + MatHint, + MatLabel, + MatSuffix, } from '@angular/material/form-field'; -import {MatIconModule} from '@angular/material/icon'; -import {Subject} from 'rxjs'; +import {MatIcon} from '@angular/material/icon'; /** @title Form field with custom telephone number input control. */ @Component({ selector: 'form-field-custom-control-example', templateUrl: 'form-field-custom-control-example.html', imports: [ - FormsModule, - ReactiveFormsModule, - MatFormFieldModule, + FormField, + MatFormField, + MatHint, + MatLabel, forwardRef(() => MyTelInput), - MatIconModule, - AsyncPipe, + MatIcon, JsonPipe, + MatSuffix, ], }) export class FormFieldCustomControlExample { - readonly form = new FormGroup({ - tel: new FormControl(null), + readonly formModel = signal<{tel: MyTel | null}>({tel: null}); + + readonly form = form(this.formModel, schemaPath => { + required(schemaPath.tel); }); } /** Data structure for holding telephone number. */ export class MyTel { constructor( - public area: string, - public exchange: string, - public subscriber: string, + readonly area: string, + readonly exchange: string, + readonly subscriber: string, ) {} } @@ -71,167 +72,115 @@ export class MyTel { styleUrl: 'example-tel-input-example.css', providers: [{provide: MatFormFieldControl, useExisting: MyTelInput}], host: { - '[class.example-floating]': 'shouldLabelFloat', + '[class.example-floating]': 'shouldLabelFloat()', '[id]': 'id', }, - imports: [FormsModule, ReactiveFormsModule], + imports: [FormField], }) -export class MyTelInput implements ControlValueAccessor, MatFormFieldControl, OnDestroy { +export class MyTelInput implements FormValueControl, MatFormFieldControl { static nextId = 0; - readonly areaInput = viewChild.required('area'); - readonly exchangeInput = viewChild.required('exchange'); - readonly subscriberInput = viewChild.required('subscriber'); - ngControl = inject(NgControl, {optional: true, self: true}); - readonly parts: FormGroup<{ - area: FormControl; - exchange: FormControl; - subscriber: FormControl; - }>; - readonly stateChanges = new Subject(); - readonly touched = signal(false); - readonly controlType = 'example-tel-input'; - readonly id = `example-tel-input-${MyTelInput.nextId++}`; - readonly _userAriaDescribedBy = input('', {alias: 'aria-describedby'}); - readonly _placeholder = input('', {alias: 'placeholder'}); - readonly _required = input(false, { - alias: 'required', - transform: booleanAttribute, - }); - readonly _disabledByInput = input(false, { - alias: 'disabled', - transform: booleanAttribute, - }); - readonly _value = model(null, {alias: 'value'}); - onChange = (_: any) => {}; - onTouched = () => {}; - - protected readonly _formField = inject(MAT_FORM_FIELD, { - optional: true, - }); - - private readonly _focused = signal(false); - private readonly _disabledByCva = signal(false); - private readonly _disabled = computed(() => this._disabledByInput() || this._disabledByCva()); - private readonly _focusMonitor = inject(FocusMonitor); + readonly ngControl = null; + protected readonly _formField = inject(MAT_FORM_FIELD, {optional: true}); + private readonly _formFieldControl = inject(FORM_FIELD, {optional: true, self: true}); private readonly _elementRef = inject>(ElementRef); + protected readonly _areaInput = viewChild.required>('area'); + protected readonly _exchangeInput = viewChild.required>('exchange'); + protected readonly _subscriberInput = + viewChild.required>('subscriber'); + private readonly _touched = signal(false); - get focused(): boolean { - return this._focused(); - } - - get empty() { - const { - value: {area, exchange, subscriber}, - } = this.parts; - - return !area && !exchange && !subscriber; - } - - get shouldLabelFloat() { - return this.focused || !this.empty; + get ngField(): Field | null { + return (this._formFieldControl?.field() as Field) ?? null; } - get userAriaDescribedBy() { - return this._userAriaDescribedBy(); - } + readonly partsModel = signal({ + area: '', + exchange: '', + subscriber: '', + }); - get placeholder(): string { - return this._placeholder(); - } + readonly parts = form(this.partsModel, schemaPath => { + required(schemaPath.area); + minLength(schemaPath.area, 3); + maxLength(schemaPath.area, 3); + required(schemaPath.exchange); + minLength(schemaPath.exchange, 3); + maxLength(schemaPath.exchange, 3); + required(schemaPath.subscriber); + minLength(schemaPath.subscriber, 4); + maxLength(schemaPath.subscriber, 4); + }); - get required(): boolean { - return this._required(); - } + readonly value = model(null); + readonly controlType = 'example-tel-input'; + readonly id = `example-tel-input-${MyTelInput.nextId++}`; + readonly userAriaDescribedBy = input('', {alias: 'aria-describedby'}); + readonly placeholder = input(''); + readonly required = input(false, {transform: booleanAttribute}); + readonly disabled = input(false, {transform: booleanAttribute}); + readonly focused = signal(false); + readonly empty = computed(() => { + const {area, exchange, subscriber} = this.partsModel(); + return !area && !exchange && !subscriber; + }); - get disabled(): boolean { - return this._disabled(); - } + readonly shouldLabelFloat = computed(() => { + const focused = this.focused(); + const empty = this.empty(); + return focused || !empty; + }); - get value(): MyTel | null { - return this._value(); - } + readonly errorState = computed(() => { + const partsValid = this.parts().valid(); + const touched = this._touched(); + return !partsValid && touched; + }); - get errorState(): boolean { - return this.parts.invalid && this.touched(); - } constructor() { - if (this.ngControl != null) { - this.ngControl.valueAccessor = this; - } - - this.parts = inject(FormBuilder).group({ - area: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(3)]], - exchange: ['', [Validators.required, Validators.minLength(3), Validators.maxLength(3)]], - subscriber: ['', [Validators.required, Validators.minLength(4), Validators.maxLength(4)]], - }); - effect(() => { - // Read signals to trigger effect. - this._placeholder(); - this._required(); - this._disabled(); - this._focused(); - // Propagate state changes. - untracked(() => this.stateChanges.next()); + const {area, exchange, subscriber} = this.partsModel(); + this.value.set(this.parts().valid() ? new MyTel(area, exchange, subscriber) : null); }); effect(() => { - if (this._disabled()) { - untracked(() => this.parts.disable()); - } else { - untracked(() => this.parts.enable()); - } + const value = this.value() || new MyTel('', '', ''); + untracked(() => { + const current = this.partsModel(); + if ( + current.area !== value.area || + current.exchange !== value.exchange || + current.subscriber !== value.subscriber + ) { + this.partsModel.set({ + area: value.area, + exchange: value.exchange, + subscriber: value.subscriber, + }); + } + }); }); - - effect(() => { - const value = this._value() || new MyTel('', '', ''); - untracked(() => this.parts.setValue(value)); - }); - - this.parts.statusChanges.pipe(takeUntilDestroyed()).subscribe(() => { - this.stateChanges.next(); - }); - - this.parts.valueChanges.pipe(takeUntilDestroyed()).subscribe(value => { - const tel = this.parts.valid - ? new MyTel( - this.parts.value.area || '', - this.parts.value.exchange || '', - this.parts.value.subscriber || '', - ) - : null; - this._updateValue(tel); - }); - } - - ngOnDestroy() { - this.stateChanges.complete(); - this._focusMonitor.stopMonitoring(this._elementRef); } onFocusIn() { - if (!this._focused()) { - this._focused.set(true); - } + this.focused.set(true); } onFocusOut(event: FocusEvent) { if (!this._elementRef.nativeElement.contains(event.relatedTarget as Element)) { - this.touched.set(true); - this._focused.set(false); - this.onTouched(); + this._touched.set(true); + this.focused.set(false); } } - autoFocusNext(control: AbstractControl, nextElement?: HTMLInputElement): void { - if (!control.errors && nextElement) { - this._focusMonitor.focusVia(nextElement, 'program'); + autoFocusNext(control: Field, nextElement?: HTMLInputElement): void { + if (control().valid() && nextElement) { + nextElement.focus(); } } - autoFocusPrev(control: AbstractControl, prevElement: HTMLInputElement): void { - if (control.value.length < 1) { - this._focusMonitor.focusVia(prevElement, 'program'); + autoFocusPrev(control: Field, prevElement: HTMLInputElement): void { + if (control().value().length < 1) { + prevElement.focus(); } } @@ -243,48 +192,16 @@ export class MyTelInput implements ControlValueAccessor, MatFormFieldControl, nextElement?: HTMLInputElement): void { this.autoFocusNext(control, nextElement); - this.onChange(this.value); - } - - private _updateValue(tel: MyTel | null) { - const current = this._value(); - if ( - tel === current || - (tel?.area === current?.area && - tel?.exchange === current?.exchange && - tel?.subscriber === current?.subscriber) - ) { - return; - } - this._value.set(tel); } } 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)) {