Skip to content

Commit c4a97f1

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 e462f3d commit c4a97f1

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
@@ -18,20 +18,20 @@ describe('stepper', () => {
1818
const nextButton = element.all(by.buttonText('Next'));
1919

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

2323
nextButton.get(0).click();
2424

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

2828
await browser.wait(ExpectedConditions.not(
2929
ExpectedConditions.presenceOf(element(by.css('div.mat-ripple-element')))));
3030

3131
previousButton.get(0).click();
3232

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

3636
await browser.wait(ExpectedConditions.not(
3737
ExpectedConditions.presenceOf(element(by.css('div.mat-ripple-element')))));
@@ -73,7 +73,7 @@ describe('stepper', () => {
7373
nextButton.get(0).click();
7474

7575
expect(await element(by.css('mat-step-header[aria-selected="true"]')).getText())
76-
.toBe('1\nFill out your name');
76+
.toBe('create\nFill out your name');
7777
});
7878
});
7979
});

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',
@@ -96,6 +107,9 @@ export class CdkStep implements OnChanges {
96107
*/
97108
@Input('aria-labelledby') ariaLabelledby: string;
98109

110+
/** Alert message when there's an error. */
111+
@Input() alertMessage: string;
112+
99113
/** Whether the user can return to this step once it has been marked as complted. */
100114
@Input()
101115
get editable(): boolean { return this._editable; }
@@ -112,8 +126,15 @@ export class CdkStep implements OnChanges {
112126
}
113127
private _optional = false;
114128

115-
/** Whether step is marked as completed. */
129+
/** State of the step. */
116130
@Input()
131+
get state(): StepState | string | null { return this._state; }
132+
set state(value: StepState | string | null) {
133+
this._state = value;
134+
}
135+
private _state: StepState | string | null = null;
136+
137+
/** Whether step is marked as completed. */
117138
get completed(): boolean {
118139
return this._customCompleted == null ? this._defaultCompleted : this._customCompleted;
119140
}
@@ -126,6 +147,19 @@ export class CdkStep implements OnChanges {
126147
return this.stepControl ? this.stepControl.valid && this.interacted : this.interacted;
127148
}
128149

150+
/** Whether step has error. */
151+
get hasError(): boolean {
152+
return this._customError == null ? this._defaultError : this._customError;
153+
}
154+
set hasError(value: boolean) {
155+
this._customError = coerceBooleanProperty(value);
156+
}
157+
private _customError: boolean | null = null;
158+
159+
private get _defaultError() {
160+
return this.stepControl && this.stepControl.invalid;
161+
}
162+
129163
constructor(@Inject(forwardRef(() => CdkStepper)) private _stepper: CdkStepper) { }
130164

131165
/** Selects this step component. */
@@ -141,6 +175,10 @@ export class CdkStep implements OnChanges {
141175
this._customCompleted = false;
142176
}
143177

178+
if (this._customError != null) {
179+
this._customError = false;
180+
}
181+
144182
if (this.stepControl) {
145183
this.stepControl.reset();
146184
}
@@ -283,15 +321,27 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
283321
}
284322

285323
/** Returns the type of icon to be displayed. */
286-
_getIndicatorType(index: number): 'number' | 'edit' | 'done' {
324+
_getIndicatorType(index: number, state: StepState | string | null = null): StepState | string {
287325
const step = this._steps.toArray()[index];
288-
if (!step.completed || this._selectedIndex == index) {
289-
return 'number';
326+
const isCurrentStep = this._isCurrentStep(index);
327+
328+
if (step.hasError && !isCurrentStep) {
329+
return STEP_STATE.ERROR;
330+
} else if (step.completed && !isCurrentStep) {
331+
return STEP_STATE.DONE;
332+
} else if (step.completed && isCurrentStep) {
333+
return state || STEP_STATE.NUMBER;
334+
} else if (step.editable && isCurrentStep) {
335+
return STEP_STATE.EDIT;
290336
} else {
291-
return step.editable ? 'edit' : 'done';
337+
return state || STEP_STATE.NUMBER;
292338
}
293339
}
294340

341+
private _isCurrentStep(index: number) {
342+
return this._selectedIndex === index;
343+
}
344+
295345
/** Returns the index of the currently-focused step header. */
296346
_getFocusIndex() {
297347
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 {
@@ -17,12 +17,12 @@ $mat-step-header-icon-size: 16px !default;
1717
-webkit-tap-highlight-color: transparent;
1818
}
1919

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

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

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

0 commit comments

Comments
 (0)