Skip to content

Commit 3c91418

Browse files
committed
feat(stepper): Add support for linear stepper (#6116)
* Add form controls and custom error state matcher * Modify form controls for stepper-demo and add custom validator * Move custom step validation function so that users can simply import and use * Implement @input() stepControl for each step * Add linear attribute to stepper * Add enabling/disabling linear state of demo
1 parent 24e3359 commit 3c91418

File tree

5 files changed

+140
-23
lines changed

5 files changed

+140
-23
lines changed

src/cdk/stepper/stepper.ts

+36-4
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ import {
2424
} from '@angular/core';
2525
import {LEFT_ARROW, RIGHT_ARROW, ENTER, SPACE} from '@angular/cdk/keyboard';
2626
import {CdkStepLabel} from './step-label';
27+
import {coerceBooleanProperty} from '@angular/cdk/coercion';
28+
import {AbstractControl} from '@angular/forms';
2729

2830
/** Used to generate unique ID for each stepper component. */
2931
let nextId = 0;
@@ -45,7 +47,7 @@ export class CdkStepperSelectionEvent {
4547

4648
@Component({
4749
selector: 'cdk-step',
48-
templateUrl: 'step.html',
50+
templateUrl: 'step.html'
4951
})
5052
export class CdkStep {
5153
/** Template for step label if it exists. */
@@ -54,6 +56,17 @@ export class CdkStep {
5456
/** Template for step content. */
5557
@ViewChild(TemplateRef) content: TemplateRef<any>;
5658

59+
/** The top level abstract control of the step. */
60+
@Input()
61+
get stepControl() { return this._stepControl; }
62+
set stepControl(control: AbstractControl) {
63+
this._stepControl = control;
64+
}
65+
private _stepControl: AbstractControl;
66+
67+
/** Whether user has seen the expanded step content or not . */
68+
interacted = false;
69+
5770
/** Label of the step. */
5871
@Input()
5972
label: string;
@@ -70,7 +83,7 @@ export class CdkStep {
7083
selector: 'cdk-stepper',
7184
host: {
7285
'(focus)': '_focusStep()',
73-
'(keydown)': '_onKeydown($event)',
86+
'(keydown)': '_onKeydown($event)'
7487
},
7588
})
7689
export class CdkStepper {
@@ -80,11 +93,17 @@ export class CdkStepper {
8093
/** The list of step headers of the steps in the stepper. */
8194
_stepHeader: QueryList<ElementRef>;
8295

96+
/** Whether the validity of previous steps should be checked or not. */
97+
@Input()
98+
get linear() { return this._linear; }
99+
set linear(value: any) { this._linear = coerceBooleanProperty(value); }
100+
private _linear = false;
101+
83102
/** The index of the selected step. */
84103
@Input()
85104
get selectedIndex() { return this._selectedIndex; }
86105
set selectedIndex(index: number) {
87-
if (this._selectedIndex != index) {
106+
if (this._selectedIndex != index && !this._anyControlsInvalid(index)) {
88107
this._emitStepperSelectionEvent(index);
89108
this._focusStep(this._selectedIndex);
90109
}
@@ -153,7 +172,7 @@ export class CdkStepper {
153172
break;
154173
case SPACE:
155174
case ENTER:
156-
this._emitStepperSelectionEvent(this._focusIndex);
175+
this.selectedIndex = this._focusIndex;
157176
break;
158177
default:
159178
// Return to avoid calling preventDefault on keys that are not explicitly handled.
@@ -166,4 +185,17 @@ export class CdkStepper {
166185
this._focusIndex = index;
167186
this._stepHeader.toArray()[this._focusIndex].nativeElement.focus();
168187
}
188+
189+
private _anyControlsInvalid(index: number): boolean {
190+
const stepsArray = this._steps.toArray();
191+
stepsArray[this._selectedIndex].interacted = true;
192+
if (this._linear) {
193+
for (let i = 0; i < index; i++) {
194+
if (!stepsArray[i].stepControl.valid) {
195+
return true;
196+
}
197+
}
198+
}
199+
return false;
200+
}
169201
}

src/cdk/stepper/tsconfig-build.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"outDir": "../../../dist/packages/cdk",
55
"baseUrl": ".",
66
"paths": {
7-
"@angular/cdk/keyboard": ["../../../dist/packages/cdk/keyboard/public_api"]
7+
"@angular/cdk/keyboard": ["../../../dist/packages/cdk/keyboard/public_api"],
8+
"@angular/cdk/coercion": ["../../../dist/packages/cdk/coercion/public_api"]
89
}
910
},
1011
"files": [

src/demo-app/stepper/stepper-demo.html

+44-13
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,47 @@
1+
<h2>Linear Vertical Stepper Demo</h2>
2+
<md-checkbox [(ngModel)]="isNonLinear">Disable linear mode</md-checkbox>
3+
<form [formGroup]="formGroup">
4+
<md-vertical-stepper formArrayName="formArray" [linear]="!isNonLinear">
5+
<md-step formGroupName="0" [stepControl]="formArray.get([0])">
6+
<ng-template mdStepLabel>Fill out your name</ng-template>
7+
<md-input-container>
8+
<input mdInput placeholder="First Name" formControlName="firstNameFormCtrl" required>
9+
<md-error>This field is required</md-error>
10+
</md-input-container>
11+
12+
<md-input-container>
13+
<input mdInput placeholder="Last Name" formControlName="lastNameFormCtrl" required>
14+
<md-error>This field is required</md-error>
15+
</md-input-container>
16+
<div>
17+
<button md-button mdStepperNext type="button">Next</button>
18+
</div>
19+
</md-step>
20+
21+
<md-step formGroupName="1" [stepControl]="formArray.get([1])">
22+
<ng-template mdStepLabel>
23+
<div>Fill out your phone number</div>
24+
</ng-template>
25+
<md-input-container>
26+
<input mdInput placeholder="Phone number" formControlName="phoneFormCtrl">
27+
<md-error>This field is required</md-error>
28+
</md-input-container>
29+
<div>
30+
<button md-button mdStepperPrevious type="button">Back</button>
31+
<button md-button mdStepperNext type="button">Next</button>
32+
</div>
33+
</md-step>
34+
35+
<md-step>
36+
<ng-template mdStepLabel>Confirm your information</ng-template>
37+
Everything seems correct.
38+
<div>
39+
<button md-button>Done</button>
40+
</div>
41+
</md-step>
42+
</md-vertical-stepper>
43+
</form>
44+
145
<h2>Vertical Stepper Demo</h2>
246
<md-vertical-stepper>
347
<md-step>
@@ -134,16 +178,3 @@ <h2>Horizontal Stepper Demo with Templated Label</h2>
134178
</div>
135179
</md-step>
136180
</md-horizontal-stepper>
137-
138-
<h2>Vertical Stepper Demo</h2>
139-
<md-vertical-stepper>
140-
<md-step *ngFor="let step of steps" [label]="step.label">
141-
<md-input-container>
142-
<input mdInput placeholder="Answer" [(ngModel)]="step.content">
143-
</md-input-container>
144-
<div>
145-
<button md-button mdStepperPrevious>Back</button>
146-
<button md-button mdStepperNext>Next</button>
147-
</div>
148-
</md-step>
149-
</md-vertical-stepper>

src/demo-app/stepper/stepper-demo.ts

+24-1
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,39 @@
11
import {Component} from '@angular/core';
2+
import {Validators, FormBuilder, FormGroup} from '@angular/forms';
23

34
@Component({
45
moduleId: module.id,
56
selector: 'stepper-demo',
67
templateUrl: 'stepper-demo.html',
7-
styleUrls: ['stepper-demo.scss'],
8+
styleUrls: ['stepper-demo.scss']
89
})
910
export class StepperDemo {
11+
formGroup: FormGroup;
12+
isNonLinear = false;
13+
1014
steps = [
1115
{label: 'Confirm your name', content: 'Last name, First name.'},
1216
{label: 'Confirm your contact information', content: '123-456-7890'},
1317
{label: 'Confirm your address', content: '1600 Amphitheater Pkwy MTV'},
1418
{label: 'You are now done', content: 'Finished!'}
1519
];
20+
21+
/** Returns a FormArray with the name 'formArray'. */
22+
get formArray() { return this.formGroup.get('formArray'); }
23+
24+
constructor(private _formBuilder: FormBuilder) { }
25+
26+
ngOnInit() {
27+
this.formGroup = this._formBuilder.group({
28+
formArray: this._formBuilder.array([
29+
this._formBuilder.group({
30+
firstNameFormCtrl: ['', Validators.required],
31+
lastNameFormCtrl: ['', Validators.required],
32+
}),
33+
this._formBuilder.group({
34+
phoneFormCtrl: [''],
35+
})
36+
])
37+
});
38+
}
1639
}

src/lib/stepper/stepper.ts

+34-4
Original file line numberDiff line numberDiff line change
@@ -15,26 +15,56 @@ import {
1515
// considers such imports as unused (https://github.com/Microsoft/TypeScript/issues/14953)
1616
// tslint:disable-next-line:no-unused-variable
1717
ElementRef,
18+
Inject,
19+
Optional,
1820
QueryList,
21+
SkipSelf,
1922
ViewChildren
2023
}from '@angular/core';
2124
import {MdStepLabel} from './step-label';
25+
import {
26+
defaultErrorStateMatcher,
27+
ErrorOptions,
28+
MD_ERROR_GLOBAL_OPTIONS,
29+
ErrorStateMatcher
30+
} from '../core/error/error-options';
31+
import {FormControl, FormGroupDirective, NgForm} from '@angular/forms';
2232

2333
@Component({
2434
moduleId: module.id,
2535
selector: 'md-step, mat-step',
26-
templateUrl: 'step.html'
36+
templateUrl: 'step.html',
37+
providers: [{provide: MD_ERROR_GLOBAL_OPTIONS, useExisting: MdStep}]
2738
})
28-
export class MdStep extends CdkStep {
39+
export class MdStep extends CdkStep implements ErrorOptions {
2940
/** Content for step label given by <ng-template matStepLabel> or <ng-template mdStepLabel>. */
3041
@ContentChild(MdStepLabel) stepLabel: MdStepLabel;
3142

32-
constructor(mdStepper: MdStepper) {
43+
/** Original ErrorStateMatcher that checks the validity of form control. */
44+
private _originalErrorStateMatcher: ErrorStateMatcher;
45+
46+
constructor(mdStepper: MdStepper,
47+
@Optional() @SkipSelf() @Inject(MD_ERROR_GLOBAL_OPTIONS) errorOptions: ErrorOptions) {
3348
super(mdStepper);
49+
this._originalErrorStateMatcher =
50+
errorOptions ? errorOptions.errorStateMatcher || defaultErrorStateMatcher
51+
: defaultErrorStateMatcher;
52+
}
53+
54+
/** Custom error state matcher that additionally checks for validity of interacted form. */
55+
errorStateMatcher = (control: FormControl, form: FormGroupDirective | NgForm) => {
56+
let originalErrorState = this._originalErrorStateMatcher(control, form);
57+
58+
// Custom error state checks for the validity of form that is not submitted or touched
59+
// since user can trigger a form change by calling for another step without directly
60+
// interacting with the current form.
61+
let customErrorState = control.invalid && this.interacted;
62+
63+
return originalErrorState || customErrorState;
3464
}
3565
}
3666

37-
export class MdStepper extends CdkStepper {
67+
export class MdStepper extends CdkStepper implements ErrorOptions {
3868
/** The list of step headers of the steps in the stepper. */
3969
@ViewChildren('stepHeader') _stepHeader: QueryList<ElementRef>;
4070

0 commit comments

Comments
 (0)