diff --git a/goldens/cdk/stepper/index.api.md b/goldens/cdk/stepper/index.api.md index d45cc6768f7a..2136b8b1117d 100644 --- a/goldens/cdk/stepper/index.api.md +++ b/goldens/cdk/stepper/index.api.md @@ -9,6 +9,7 @@ import { AfterContentInit } from '@angular/core'; import { AfterViewInit } from '@angular/core'; import { ElementRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; +import { Field } from '@angular/forms/signals'; import { FormGroupDirective } from '@angular/forms'; import * as i0 from '@angular/core'; import { InjectionToken } from '@angular/core'; @@ -63,7 +64,7 @@ export class CdkStep implements OnChanges { _showError(): boolean; get state(): StepState; set state(value: StepState); - stepControl: AbstractControl; + stepControl: StepControl; stepLabel: CdkStepLabel; // (undocumented) _stepper: CdkStepper; @@ -181,6 +182,9 @@ export const STEP_STATE: { // @public export type StepContentPositionState = 'previous' | 'current' | 'next'; +// @public +export type StepControl = AbstractControl | Field; + // @public export const STEPPER_GLOBAL_OPTIONS: InjectionToken; diff --git a/src/cdk/stepper/stepper.ts b/src/cdk/stepper/stepper.ts index da55ca2f61a5..da7b6563ddb9 100644 --- a/src/cdk/stepper/stepper.ts +++ b/src/cdk/stepper/stepper.ts @@ -40,6 +40,7 @@ import { type NgForm, type FormGroupDirective, } from '@angular/forms'; +import type {Field} from '@angular/forms/signals'; import {_getFocusedElementPierceShadowDom} from '../platform'; import {Observable, of as observableOf, Subject} from 'rxjs'; import {startWith, takeUntil} from 'rxjs/operators'; @@ -56,6 +57,9 @@ export type StepContentPositionState = 'previous' | 'current' | 'next'; /** Possible orientation of a stepper. */ export type StepperOrientation = 'horizontal' | 'vertical'; +/** Possible controls that can be assigned to a step. */ +export type StepControl = AbstractControl | Field; + /** Change event emitted on selection changes. */ export class StepperSelectionEvent { /** Index of the step now selected. */ @@ -132,7 +136,7 @@ export class CdkStep implements OnChanges { @ViewChild(TemplateRef, {static: true}) content!: TemplateRef; /** The top level abstract control of the step. */ - @Input() stepControl!: AbstractControl; + @Input() stepControl!: StepControl; /** Whether user has attempted to move away from the step. */ get interacted(): boolean { @@ -195,7 +199,7 @@ export class CdkStep implements OnChanges { return override; } - return interacted && (!this.stepControl || this.stepControl.valid); + return interacted && (!this.stepControl || isValid(this.stepControl)); } set completed(value: boolean) { this._completedOverride.set(value); @@ -253,7 +257,7 @@ export class CdkStep implements OnChanges { private _customError = signal(null); private _getDefaultError() { - return this.interacted && !!this.stepControl?.invalid; + return this.interacted && !!this.stepControl && isInvalid(this.stepControl); } constructor() { @@ -284,7 +288,7 @@ export class CdkStep implements OnChanges { // want the form to be back to its initial state (see #29781). Submitted state is on the // individual directives, rather than the control, so we need to reset them ourselves. this._childForms?.forEach(form => form.resetForm?.()); - this.stepControl.reset(); + reset(this.stepControl); } } @@ -593,7 +597,7 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { .some(step => { const control = step.stepControl; const isIncomplete = control - ? control.invalid || control.pending || !step.interacted + ? isInvalid(control) || isPending(control) || !step.interacted : !step.completed; return isIncomplete && !step.optional && !step._completedOverride(); }); @@ -618,3 +622,29 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy { return index > -1 && (!this.steps || index < this.steps.length); } } + +function isField(value: StepControl): value is Field { + return typeof value === 'function'; +} + +function isValid(control: StepControl): boolean { + return isField(control) ? control().valid() : control.valid; +} + +function isInvalid(control: StepControl): boolean { + // Note: it's a bit redundant to have both `isValid` and `isInvalid`. We need both, because + // some internal apps mock out `invalid` specifically so `!valid` won't hit the mock. + return isField(control) ? control().invalid() : control.invalid; +} + +function isPending(control: StepControl): boolean { + return isField(control) ? control().pending() : control.pending; +} + +function reset(control: StepControl): void { + if (isField(control)) { + control().reset(); + } else { + control.reset(); + } +} diff --git a/src/components-examples/material/stepper/index.ts b/src/components-examples/material/stepper/index.ts index a53ab5c7d909..db761af897ac 100644 --- a/src/components-examples/material/stepper/index.ts +++ b/src/components-examples/material/stepper/index.ts @@ -11,3 +11,4 @@ export {StepperLazyContentExample} from './stepper-lazy-content/stepper-lazy-con export {StepperResponsiveExample} from './stepper-responsive/stepper-responsive-example'; export {StepperHeaderPositionExample} from './stepper-header-position/stepper-header-position-example'; export {StepperAnimationsExample} from './stepper-animations/stepper-animations-example'; +export {StepperSignalFormsExample} from './stepper-signal-forms/stepper-signal-forms-example'; diff --git a/src/components-examples/material/stepper/stepper-signal-forms/stepper-signal-forms-example.css b/src/components-examples/material/stepper/stepper-signal-forms/stepper-signal-forms-example.css new file mode 100644 index 000000000000..054169ceca1f --- /dev/null +++ b/src/components-examples/material/stepper/stepper-signal-forms/stepper-signal-forms-example.css @@ -0,0 +1,7 @@ +.mat-stepper-horizontal { + margin-top: 8px; +} + +.mat-mdc-form-field { + margin-top: 16px; +} diff --git a/src/components-examples/material/stepper/stepper-signal-forms/stepper-signal-forms-example.html b/src/components-examples/material/stepper/stepper-signal-forms/stepper-signal-forms-example.html new file mode 100644 index 000000000000..0376d7160051 --- /dev/null +++ b/src/components-examples/material/stepper/stepper-signal-forms/stepper-signal-forms-example.html @@ -0,0 +1,38 @@ + + + + +
+ Fill out your name + + Name + + +
+ +
+
+
+ +
+ + Address + + +
+ + +
+
+
+ + Done +

You are now done.

+
+ + +
+
+
diff --git a/src/components-examples/material/stepper/stepper-signal-forms/stepper-signal-forms-example.ts b/src/components-examples/material/stepper/stepper-signal-forms/stepper-signal-forms-example.ts new file mode 100644 index 000000000000..4c35812b90bc --- /dev/null +++ b/src/components-examples/material/stepper/stepper-signal-forms/stepper-signal-forms-example.ts @@ -0,0 +1,27 @@ +import {Component, signal} from '@angular/core'; +import {form, required, FormField} from '@angular/forms/signals'; +import {MatButtonModule} from '@angular/material/button'; +import {MatFormFieldModule} from '@angular/material/form-field'; +import {MatInputModule} from '@angular/material/input'; +import {MatStepperModule} from '@angular/material/stepper'; + +/** + * @title Stepper using signal forms + */ +@Component({ + selector: 'stepper-signal-forms-example', + templateUrl: 'stepper-signal-forms-example.html', + styleUrl: 'stepper-signal-forms-example.css', + imports: [FormField, MatButtonModule, MatFormFieldModule, MatInputModule, MatStepperModule], +}) +export class StepperSignalFormsExample { + readonly nameFormGroup = form(signal({name: ''}), tree => { + required(tree.name); + }); + + readonly adddressFormGroup = form(signal({address: ''}), tree => { + required(tree.address); + }); + + readonly isLinear = signal(false); +} diff --git a/src/material/stepper/stepper.md b/src/material/stepper/stepper.md index e264ae642c34..f012d4520ef8 100644 --- a/src/material/stepper/stepper.md +++ b/src/material/stepper/stepper.md @@ -101,6 +101,12 @@ are completed. ``` + +#### Using Signal Forms +The stepper also supports passing in a Signal Forms field as the `stepControl`: + + + ### Types of steps #### Optional step