Skip to content

Commit 0e34c64

Browse files
authored
fix(cdk/stepper): allow signal form to be assigned as stepControl (#33562)
* fix(cdk/stepper): allow signal form to be assigned as stepControl Allows users to assign a signal form as the `stepControl`. Fixes #33447. * docs(material/stepper): add example using signal forms Adds an example for the Material stepper that uses signal forms.
1 parent 56c7ff6 commit 0e34c64

7 files changed

Lines changed: 119 additions & 6 deletions

File tree

goldens/cdk/stepper/index.api.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { AfterContentInit } from '@angular/core';
99
import { AfterViewInit } from '@angular/core';
1010
import { ElementRef } from '@angular/core';
1111
import { EventEmitter } from '@angular/core';
12+
import { Field } from '@angular/forms/signals';
1213
import { FormGroupDirective } from '@angular/forms';
1314
import * as i0 from '@angular/core';
1415
import { InjectionToken } from '@angular/core';
@@ -63,7 +64,7 @@ export class CdkStep implements OnChanges {
6364
_showError(): boolean;
6465
get state(): StepState;
6566
set state(value: StepState);
66-
stepControl: AbstractControl;
67+
stepControl: StepControl;
6768
stepLabel: CdkStepLabel;
6869
// (undocumented)
6970
_stepper: CdkStepper;
@@ -181,6 +182,9 @@ export const STEP_STATE: {
181182
// @public
182183
export type StepContentPositionState = 'previous' | 'current' | 'next';
183184

185+
// @public
186+
export type StepControl = AbstractControl | Field<unknown>;
187+
184188
// @public
185189
export const STEPPER_GLOBAL_OPTIONS: InjectionToken<StepperOptions>;
186190

src/cdk/stepper/stepper.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
type NgForm,
4141
type FormGroupDirective,
4242
} from '@angular/forms';
43+
import type {Field} from '@angular/forms/signals';
4344
import {_getFocusedElementPierceShadowDom} from '../platform';
4445
import {Observable, of as observableOf, Subject} from 'rxjs';
4546
import {startWith, takeUntil} from 'rxjs/operators';
@@ -56,6 +57,9 @@ export type StepContentPositionState = 'previous' | 'current' | 'next';
5657
/** Possible orientation of a stepper. */
5758
export type StepperOrientation = 'horizontal' | 'vertical';
5859

60+
/** Possible controls that can be assigned to a step. */
61+
export type StepControl = AbstractControl | Field<unknown>;
62+
5963
/** Change event emitted on selection changes. */
6064
export class StepperSelectionEvent {
6165
/** Index of the step now selected. */
@@ -132,7 +136,7 @@ export class CdkStep implements OnChanges {
132136
@ViewChild(TemplateRef, {static: true}) content!: TemplateRef<any>;
133137

134138
/** The top level abstract control of the step. */
135-
@Input() stepControl!: AbstractControl;
139+
@Input() stepControl!: StepControl;
136140

137141
/** Whether user has attempted to move away from the step. */
138142
get interacted(): boolean {
@@ -195,7 +199,7 @@ export class CdkStep implements OnChanges {
195199
return override;
196200
}
197201

198-
return interacted && (!this.stepControl || this.stepControl.valid);
202+
return interacted && (!this.stepControl || isValid(this.stepControl));
199203
}
200204
set completed(value: boolean) {
201205
this._completedOverride.set(value);
@@ -253,7 +257,7 @@ export class CdkStep implements OnChanges {
253257
private _customError = signal<boolean | null>(null);
254258

255259
private _getDefaultError() {
256-
return this.interacted && !!this.stepControl?.invalid;
260+
return this.interacted && !!this.stepControl && isInvalid(this.stepControl);
257261
}
258262

259263
constructor() {
@@ -284,7 +288,7 @@ export class CdkStep implements OnChanges {
284288
// want the form to be back to its initial state (see #29781). Submitted state is on the
285289
// individual directives, rather than the control, so we need to reset them ourselves.
286290
this._childForms?.forEach(form => form.resetForm?.());
287-
this.stepControl.reset();
291+
reset(this.stepControl);
288292
}
289293
}
290294

@@ -593,7 +597,7 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy {
593597
.some(step => {
594598
const control = step.stepControl;
595599
const isIncomplete = control
596-
? control.invalid || control.pending || !step.interacted
600+
? isInvalid(control) || isPending(control) || !step.interacted
597601
: !step.completed;
598602
return isIncomplete && !step.optional && !step._completedOverride();
599603
});
@@ -618,3 +622,29 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy {
618622
return index > -1 && (!this.steps || index < this.steps.length);
619623
}
620624
}
625+
626+
function isField(value: StepControl): value is Field<unknown> {
627+
return typeof value === 'function';
628+
}
629+
630+
function isValid(control: StepControl): boolean {
631+
return isField(control) ? control().valid() : control.valid;
632+
}
633+
634+
function isInvalid(control: StepControl): boolean {
635+
// Note: it's a bit redundant to have both `isValid` and `isInvalid`. We need both, because
636+
// some internal apps mock out `invalid` specifically so `!valid` won't hit the mock.
637+
return isField(control) ? control().invalid() : control.invalid;
638+
}
639+
640+
function isPending(control: StepControl): boolean {
641+
return isField(control) ? control().pending() : control.pending;
642+
}
643+
644+
function reset(control: StepControl): void {
645+
if (isField(control)) {
646+
control().reset();
647+
} else {
648+
control.reset();
649+
}
650+
}

src/components-examples/material/stepper/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ export {StepperLazyContentExample} from './stepper-lazy-content/stepper-lazy-con
1111
export {StepperResponsiveExample} from './stepper-responsive/stepper-responsive-example';
1212
export {StepperHeaderPositionExample} from './stepper-header-position/stepper-header-position-example';
1313
export {StepperAnimationsExample} from './stepper-animations/stepper-animations-example';
14+
export {StepperSignalFormsExample} from './stepper-signal-forms/stepper-signal-forms-example';
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
.mat-stepper-horizontal {
2+
margin-top: 8px;
3+
}
4+
5+
.mat-mdc-form-field {
6+
margin-top: 16px;
7+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<button matButton="elevated" (click)="isLinear.set(!isLinear())">
2+
{{!isLinear ? 'Enable linear mode' : 'Disable linear mode'}}
3+
</button>
4+
5+
<mat-stepper [linear]="isLinear()" #stepper>
6+
<mat-step [stepControl]="nameFormGroup">
7+
<form (submit)="$event.preventDefault()">
8+
<ng-template matStepLabel>Fill out your name</ng-template>
9+
<mat-form-field>
10+
<mat-label>Name</mat-label>
11+
<input matInput placeholder="Last name, First name" [formField]="nameFormGroup.name">
12+
</mat-form-field>
13+
<div>
14+
<button matButton matStepperNext>Next</button>
15+
</div>
16+
</form>
17+
</mat-step>
18+
<mat-step [stepControl]="adddressFormGroup" label="Fill out your address">
19+
<form (submit)="$event.preventDefault()">
20+
<mat-form-field>
21+
<mat-label>Address</mat-label>
22+
<input matInput [formField]="adddressFormGroup.address" placeholder="Ex. 1 Main St, New York, NY">
23+
</mat-form-field>
24+
<div>
25+
<button matButton matStepperPrevious>Back</button>
26+
<button matButton matStepperNext>Next</button>
27+
</div>
28+
</form>
29+
</mat-step>
30+
<mat-step>
31+
<ng-template matStepLabel>Done</ng-template>
32+
<p>You are now done.</p>
33+
<div>
34+
<button matButton matStepperPrevious>Back</button>
35+
<button matButton (click)="stepper.reset()">Reset</button>
36+
</div>
37+
</mat-step>
38+
</mat-stepper>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {Component, signal} from '@angular/core';
2+
import {form, required, FormField} from '@angular/forms/signals';
3+
import {MatButtonModule} from '@angular/material/button';
4+
import {MatFormFieldModule} from '@angular/material/form-field';
5+
import {MatInputModule} from '@angular/material/input';
6+
import {MatStepperModule} from '@angular/material/stepper';
7+
8+
/**
9+
* @title Stepper using signal forms
10+
*/
11+
@Component({
12+
selector: 'stepper-signal-forms-example',
13+
templateUrl: 'stepper-signal-forms-example.html',
14+
styleUrl: 'stepper-signal-forms-example.css',
15+
imports: [FormField, MatButtonModule, MatFormFieldModule, MatInputModule, MatStepperModule],
16+
})
17+
export class StepperSignalFormsExample {
18+
readonly nameFormGroup = form(signal({name: ''}), tree => {
19+
required(tree.name);
20+
});
21+
22+
readonly adddressFormGroup = form(signal({address: ''}), tree => {
23+
required(tree.address);
24+
});
25+
26+
readonly isLinear = signal(false);
27+
}

src/material/stepper/stepper.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ are completed.
101101
</mat-step>
102102
</mat-stepper>
103103
```
104+
105+
#### Using Signal Forms
106+
The stepper also supports passing in a Signal Forms field as the `stepControl`:
107+
108+
<!-- example(stepper-signal-forms) -->
109+
104110
### Types of steps
105111

106112
#### Optional step

0 commit comments

Comments
 (0)