Skip to content

Commit 1c89de9

Browse files
committed
feat(stepper): add state input to step
feat: added error state in order to display the correct icon and a default state to display any custom state/icon refactor: add StepState type refactor: refactor getIndicatorType to become more readable feat: add stepper example using a custom state feat: add error step theme feat: update getIndicatorType logic to handle a custom state feat: add alertMessage input for the step and conditionally apply error css feat: add icon override demo example and allow any icon to be overridden fix: issue with icon colors when selected or not test: update/add unit tests for the stepper changes test: update e2e tests for the stepper changes docs(stepper): update the readme to include new functionality refactor: add missing comment refactor: use mixins for stepper theme error
1 parent e6f3062 commit 1c89de9

File tree

14 files changed

+308
-62
lines changed

14 files changed

+308
-62
lines changed

e2e/components/stepper-e2e.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ describe('stepper', () => {
2020
const nextButton = element.all(by.buttonText('Next'));
2121

2222
expect(await element(by.css('mat-step-header[aria-selected="true"]')).getText())
23-
.toBe('1\nFill out your name');
23+
.toBe('create\nFill out your name');
2424

2525
screenshot('start');
2626
nextButton.get(0).click();
2727

2828
expect(await element(by.css('mat-step-header[aria-selected="true"]')).getText())
29-
.toBe('2\nFill out your address');
29+
.toBe('create\nFill out your address');
3030

3131
await browser.wait(ExpectedConditions.not(
3232
ExpectedConditions.presenceOf(element(by.css('div.mat-ripple-element')))));
@@ -35,7 +35,7 @@ describe('stepper', () => {
3535
previousButton.get(0).click();
3636

3737
expect(await element(by.css('mat-step-header[aria-selected="true"]')).getText())
38-
.toBe('1\nFill out your name');
38+
.toBe('create\nFill out your name');
3939

4040
await browser.wait(ExpectedConditions.not(
4141
ExpectedConditions.presenceOf(element(by.css('div.mat-ripple-element')))));
@@ -78,7 +78,7 @@ describe('stepper', () => {
7878
nextButton.get(0).click();
7979

8080
expect(await element(by.css('mat-step-header[aria-selected="true"]')).getText())
81-
.toBe('1\nFill out your name');
81+
.toBe('create\nFill out your name');
8282
});
8383
});
8484
});

src/cdk/stepper/stepper.ts

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ export class StepperSelectionEvent {
6363
previouslySelectedStep: CdkStep;
6464
}
6565

66+
/** The state of each step. */
67+
export type StepState = 'number' | 'edit' | 'done' | 'error';
68+
69+
/** Enum to represent the different states of the steps. */
70+
export const STEP_STATE = {
71+
NUMBER: 'number',
72+
EDIT: 'edit',
73+
DONE: 'done',
74+
ERROR: 'error'
75+
};
76+
6677
@Component({
6778
moduleId: module.id,
6879
selector: 'cdk-step',
@@ -87,6 +98,9 @@ export class CdkStep implements OnChanges {
8798
/** Label of the step. */
8899
@Input() label: string;
89100

101+
/** Alert message when there's an error. */
102+
@Input() alertMessage: string;
103+
90104
/** Whether the user can return to this step once it has been marked as complted. */
91105
@Input()
92106
get editable(): boolean { return this._editable; }
@@ -103,8 +117,15 @@ export class CdkStep implements OnChanges {
103117
}
104118
private _optional = false;
105119

106-
/** Whether step is marked as completed. */
120+
/** State of the step. */
107121
@Input()
122+
get state(): StepState | string | null { return this._state; }
123+
set state(value: StepState | string | null) {
124+
this._state = value;
125+
}
126+
private _state: StepState | string | null = null;
127+
128+
/** Whether step is marked as completed. */
108129
get completed(): boolean {
109130
return this._customCompleted == null ? this._defaultCompleted : this._customCompleted;
110131
}
@@ -117,6 +138,19 @@ export class CdkStep implements OnChanges {
117138
return this.stepControl ? this.stepControl.valid && this.interacted : this.interacted;
118139
}
119140

141+
/** Whether step has error. */
142+
get hasError(): boolean {
143+
return this._customError == null ? this._defaultError : this._customError;
144+
}
145+
set hasError(value: boolean) {
146+
this._customError = coerceBooleanProperty(value);
147+
}
148+
private _customError: boolean | null = null;
149+
150+
private get _defaultError() {
151+
return this.stepControl && this.stepControl.invalid;
152+
}
153+
120154
constructor(@Inject(forwardRef(() => CdkStepper)) private _stepper: CdkStepper) { }
121155

122156
/** Selects this step component. */
@@ -132,6 +166,10 @@ export class CdkStep implements OnChanges {
132166
this._customCompleted = false;
133167
}
134168

169+
if (this._customError != null) {
170+
this._customError = false;
171+
}
172+
135173
if (this.stepControl) {
136174
this.stepControl.reset();
137175
}
@@ -274,15 +312,27 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
274312
}
275313

276314
/** Returns the type of icon to be displayed. */
277-
_getIndicatorType(index: number): 'number' | 'edit' | 'done' {
315+
_getIndicatorType(index: number, state: StepState | string | null = null): StepState | string {
278316
const step = this._steps.toArray()[index];
279-
if (!step.completed || this._selectedIndex == index) {
280-
return 'number';
317+
const isCurrentStep = this._isCurrentStep(index);
318+
319+
if (step.hasError && !isCurrentStep) {
320+
return STEP_STATE.ERROR;
321+
} else if (step.completed && !isCurrentStep) {
322+
return STEP_STATE.DONE;
323+
} else if (step.completed && isCurrentStep) {
324+
return state || STEP_STATE.NUMBER;
325+
} else if (step.editable && isCurrentStep) {
326+
return STEP_STATE.EDIT;
281327
} else {
282-
return step.editable ? 'edit' : 'done';
328+
return state || STEP_STATE.NUMBER;
283329
}
284330
}
285331

332+
private _isCurrentStep(index: number) {
333+
return this._selectedIndex === index;
334+
}
335+
286336
/** Returns the index of the currently-focused step header. */
287337
_getFocusIndex() {
288338
return this._keyManager ? this._keyManager.activeItemIndex : this._selectedIndex;

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

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
<h3>Linear Vertical Stepper Demo using a single form</h3>
44
<form [formGroup]="formGroup">
55
<mat-vertical-stepper #linearVerticalStepper="matVerticalStepper" formArrayName="formArray" [linear]="!isNonLinear">
6-
<mat-step formGroupName="0" [stepControl]="formArray?.get([0])">
6+
<mat-step
7+
formGroupName="0"
8+
[stepControl]="formArray?.get([0])"
9+
alertMessage="Some fields are required"
10+
>
711
<ng-template matStepLabel>Fill out your name</ng-template>
812
<mat-form-field>
913
<mat-label>First name</mat-label>
@@ -21,7 +25,12 @@ <h3>Linear Vertical Stepper Demo using a single form</h3>
2125
</div>
2226
</mat-step>
2327

24-
<mat-step formGroupName="1" [stepControl]="formArray?.get([1])" optional>
28+
<mat-step
29+
formGroupName="1"
30+
[stepControl]="formArray?.get([1])"
31+
optional
32+
alertMessage="The input is invalid"
33+
>
2534
<ng-template matStepLabel>
2635
<div>Fill out your email address</div>
2736
</ng-template>
@@ -223,3 +232,64 @@ <h3>Stepper with autosize textarea</h3>
223232
</mat-step>
224233
</mat-horizontal-stepper>
225234

235+
<h3>Stepper with customized state</h3>
236+
<mat-horizontal-stepper [linear]="true">
237+
<mat-step label="Step 1" state="shopping_cart">
238+
<p>Go to your shopping cart.</p>
239+
<div>
240+
<button mat-button matStepperNext>Next</button>
241+
</div>
242+
</mat-step>
243+
<mat-step label="Step 2" state="credit_card">
244+
<p>Enter your credit card information.</p>
245+
<div>
246+
<button mat-button matStepperPrevious>Back</button>
247+
<button mat-button matStepperNext>Next</button>
248+
</div>
249+
</mat-step>
250+
<mat-step label="Step 3" state="receipt">
251+
<p>Get your receipt.</p>
252+
<div>
253+
<button mat-button matStepperPrevious>Back</button>
254+
<button mat-button matStepperNext>Next</button>
255+
</div>
256+
</mat-step>
257+
<mat-step label="Step 4" state="print">
258+
<p>Print your receipt.</p>
259+
<div>
260+
<button mat-button matStepperPrevious>Back</button>
261+
<button mat-button matStepperNext>Next</button>
262+
</div>
263+
</mat-step>
264+
<mat-step label="Step 5">
265+
<p>You've successfully completed your purchase!</p>
266+
</mat-step>
267+
</mat-horizontal-stepper>
268+
269+
<h3>Stepper with customized state with icon overrides</h3>
270+
<mat-horizontal-stepper [linear]="true">
271+
<mat-step label="Step 1" state="phone">
272+
<p>Put down your phones.</p>
273+
<div>
274+
<button mat-button matStepperNext>Next</button>
275+
</div>
276+
</mat-step>
277+
<mat-step label="Step 2" state="chat">
278+
<p>Socialize with each other.</p>
279+
<div>
280+
<button mat-button matStepperPrevious>Back</button>
281+
<button mat-button matStepperNext>Next</button>
282+
</div>
283+
</mat-step>
284+
<mat-step label="Step 3">
285+
<p>You're welcome.</p>
286+
</mat-step>
287+
288+
<!-- Icon overrides. -->
289+
<ng-template matStepperIcon="phone">
290+
<mat-icon>call_end</mat-icon>
291+
</ng-template>
292+
<ng-template matStepperIcon="chat">
293+
<mat-icon>forum</mat-icon>
294+
</ng-template>
295+
</mat-horizontal-stepper>

src/lib/stepper/_stepper-theme.scss

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
$foreground: map-get($theme, foreground);
77
$background: map-get($theme, background);
88
$primary: map-get($theme, primary);
9+
$warn: map-get($theme, warn);
910

1011
.mat-step-header {
1112
&.cdk-keyboard-focused,
@@ -20,18 +21,31 @@
2021
}
2122

2223
.mat-step-icon {
24+
color: mat-color($primary, default-contrast);
25+
}
26+
27+
.mat-step-icon-selected {
2328
background-color: mat-color($primary);
2429
color: mat-color($primary, default-contrast);
2530
}
2631

27-
.mat-step-icon-not-touched {
32+
.mat-step-icon-not-selected {
2833
background-color: mat-color($foreground, disabled-text);
2934
color: mat-color($primary, default-contrast);
3035
}
3136

37+
.mat-step-icon-error {
38+
background-color: transparent;
39+
color: mat-color($warn);
40+
}
41+
3242
.mat-step-label.mat-step-label-active {
3343
color: mat-color($foreground, text);
3444
}
45+
46+
.mat-step-label.mat-step-label-error {
47+
color: mat-color($warn);
48+
}
3549
}
3650

3751
.mat-stepper-horizontal, .mat-stepper-vertical {
@@ -59,6 +73,14 @@
5973
};
6074
}
6175

76+
.mat-step-sub-label-error {
77+
font-weight: normal;
78+
}
79+
80+
.mat-step-label-error {
81+
font-size: mat-font-size($config, body-2);
82+
}
83+
6284
.mat-step-label-selected {
6385
font: {
6486
size: mat-font-size($config, body-2);

src/lib/stepper/step-header.html

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<div class="mat-step-header-ripple" mat-ripple [matRippleTrigger]="_getHostElement()"></div>
2-
<div [class.mat-step-icon]="state !== 'number' || selected"
3-
[class.mat-step-icon-not-touched]="state == 'number' && !selected"
2+
<div class="mat-step-icon"
3+
[class.mat-step-icon-selected]="selected || state == 'done'"
4+
[class.mat-step-icon-not-selected]="!selected && state != 'done'"
5+
[class.mat-step-icon-error]="state == 'error'"
46
[ngSwitch]="state">
57

68
<ng-container *ngSwitchCase="'number'" [ngSwitch]="!!(iconOverrides && iconOverrides.number)">
@@ -26,16 +28,35 @@
2628
[ngTemplateOutletContext]="_getIconContext()"></ng-container>
2729
<mat-icon *ngSwitchDefault>done</mat-icon>
2830
</ng-container>
31+
32+
<ng-container *ngSwitchCase="'error'" [ngSwitch]="!!(iconOverrides && iconOverrides.error)">
33+
<ng-container
34+
*ngSwitchCase="true"
35+
[ngTemplateOutlet]="iconOverrides.error"
36+
[ngTemplateOutletContext]="_getIconContext()"></ng-container>
37+
<mat-icon *ngSwitchDefault>warning</mat-icon>
38+
</ng-container>
39+
40+
<!-- Custom state. -->
41+
<ng-container *ngSwitchDefault [ngSwitch]="!!(iconOverrides && iconOverrides[state])">
42+
<ng-container
43+
*ngSwitchCase="true"
44+
[ngTemplateOutlet]="iconOverrides[state]"
45+
[ngTemplateOutletContext]="_getIconContext()"></ng-container>
46+
<mat-icon *ngSwitchDefault>{{state}}</mat-icon>
47+
</ng-container>
2948
</div>
3049
<div class="mat-step-label"
3150
[class.mat-step-label-active]="active"
32-
[class.mat-step-label-selected]="selected">
51+
[class.mat-step-label-selected]="selected"
52+
[class.mat-step-label-error]="state == 'error'">
3353
<!-- If there is a label template, use it. -->
3454
<ng-container *ngIf="_templateLabel()" [ngTemplateOutlet]="_templateLabel()!.template">
3555
</ng-container>
36-
<!-- It there is no label template, fall back to the text label. -->
56+
<!-- If there is no label template, fall back to the text label. -->
3757
<div class="mat-step-text-label" *ngIf="_stringLabel()">{{label}}</div>
3858

39-
<div class="mat-step-optional" *ngIf="optional">{{_intl.optionalLabel}}</div>
59+
<div class="mat-step-optional" *ngIf="optional && state != 'error'">{{_intl.optionalLabel}}</div>
60+
<div class="mat-step-sub-label-error" *ngIf="state == 'error'">{{alertMessage}}</div>
4061
</div>
4162

src/lib/stepper/step-header.scss

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ $mat-stepper-label-min-width: 50px !default;
55
$mat-stepper-side-gap: 24px !default;
66
$mat-vertical-stepper-content-margin: 36px !default;
77
$mat-stepper-line-gap: 8px !default;
8-
$mat-step-optional-font-size: 12px;
8+
$mat-step-sub-label-font-size: 12px;
99
$mat-step-header-icon-size: 16px !default;
1010

1111
.mat-step-header {
@@ -16,12 +16,12 @@ $mat-step-header-icon-size: 16px !default;
1616
box-sizing: content-box;
1717
}
1818

19-
.mat-step-optional {
20-
font-size: $mat-step-optional-font-size;
19+
.mat-step-optional,
20+
.mat-step-sub-label-error {
21+
font-size: $mat-step-sub-label-font-size;
2122
}
2223

23-
.mat-step-icon,
24-
.mat-step-icon-not-touched {
24+
.mat-step-icon {
2525
border-radius: 50%;
2626
height: $mat-stepper-label-header-height;
2727
width: $mat-stepper-label-header-height;
@@ -37,6 +37,12 @@ $mat-step-header-icon-size: 16px !default;
3737
width: $mat-step-header-icon-size;
3838
}
3939

40+
.mat-step-icon-error .mat-icon {
41+
font-size: $mat-step-header-icon-size+8;
42+
height: $mat-step-header-icon-size+8;
43+
width: $mat-step-header-icon-size+8;
44+
}
45+
4046
.mat-step-label {
4147
display: inline-block;
4248
white-space: nowrap;

0 commit comments

Comments
 (0)