Skip to content

Commit bebb9ff

Browse files
crisbetommalerba
authored andcommitted
fix(stepper): unable to skip step if completed value is overwritten (#15403)
Currently we allow for the `completed` value of a step to be overwritten, however setting it to `true` still won't allow the user to skip the step, because the `interacted` flag won't be flipped if they haven't been on a particular step before. Fixes #15310.
1 parent 7f071c8 commit bebb9ff

File tree

3 files changed

+75
-50
lines changed

3 files changed

+75
-50
lines changed

src/cdk/stepper/stepper.ts

Lines changed: 53 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import {FocusableOption, FocusKeyManager} from '@angular/cdk/a11y';
1010
import {Direction, Directionality} from '@angular/cdk/bidi';
1111
import {coerceBooleanProperty, coerceNumberProperty} from '@angular/cdk/coercion';
12-
import {END, ENTER, HOME, SPACE, hasModifierKey} from '@angular/cdk/keycodes';
12+
import {END, ENTER, hasModifierKey, HOME, SPACE} from '@angular/cdk/keycodes';
13+
import {DOCUMENT} from '@angular/common';
1314
import {
1415
AfterViewInit,
1516
ChangeDetectionStrategy,
@@ -18,10 +19,11 @@ import {
1819
ContentChild,
1920
ContentChildren,
2021
Directive,
21-
EventEmitter,
2222
ElementRef,
23+
EventEmitter,
2324
forwardRef,
2425
Inject,
26+
InjectionToken,
2527
Input,
2628
OnChanges,
2729
OnDestroy,
@@ -31,13 +33,12 @@ import {
3133
TemplateRef,
3234
ViewChild,
3335
ViewEncapsulation,
34-
InjectionToken,
3536
} from '@angular/core';
36-
import {DOCUMENT} from '@angular/common';
37-
import {CdkStepLabel} from './step-label';
38-
import {Observable, Subject, of as obaservableOf} from 'rxjs';
37+
import {Observable, of as obaservableOf, Subject} from 'rxjs';
3938
import {startWith, takeUntil} from 'rxjs/operators';
39+
4040
import {CdkStepHeader} from './step-header';
41+
import {CdkStepLabel} from './step-label';
4142

4243
/** Used to generate unique ID for each stepper component. */
4344
let nextId = 0;
@@ -46,10 +47,10 @@ let nextId = 0;
4647
* Position state of the content of each step in stepper that is used for transitioning
4748
* the content into correct position upon step selection change.
4849
*/
49-
export type StepContentPositionState = 'previous' | 'current' | 'next';
50+
export type StepContentPositionState = 'previous'|'current'|'next';
5051

5152
/** Possible orientation of a stepper. */
52-
export type StepperOrientation = 'horizontal' | 'vertical';
53+
export type StepperOrientation = 'horizontal'|'vertical';
5354

5455
/** Change event emitted on selection changes. */
5556
export class StepperSelectionEvent {
@@ -67,7 +68,7 @@ export class StepperSelectionEvent {
6768
}
6869

6970
/** The state of each step. */
70-
export type StepState = 'number' | 'edit' | 'done' | 'error' | string;
71+
export type StepState = 'number'|'edit'|'done'|'error'|string;
7172

7273
/** Enum to represent the different states of the steps. */
7374
export const STEP_STATE = {
@@ -78,8 +79,7 @@ export const STEP_STATE = {
7879
};
7980

8081
/** InjectionToken that can be used to specify the global stepper options. */
81-
export const STEPPER_GLOBAL_OPTIONS =
82-
new InjectionToken<StepperOptions>('STEPPER_GLOBAL_OPTIONS');
82+
export const STEPPER_GLOBAL_OPTIONS = new InjectionToken<StepperOptions>('STEPPER_GLOBAL_OPTIONS');
8383

8484
/**
8585
* InjectionToken that can be used to specify the global stepper options.
@@ -149,15 +149,19 @@ export class CdkStep implements OnChanges {
149149

150150
/** Whether the user can return to this step once it has been marked as completed. */
151151
@Input()
152-
get editable(): boolean { return this._editable; }
152+
get editable(): boolean {
153+
return this._editable;
154+
}
153155
set editable(value: boolean) {
154156
this._editable = coerceBooleanProperty(value);
155157
}
156158
private _editable = true;
157159

158160
/** Whether the completion of step is optional. */
159161
@Input()
160-
get optional(): boolean { return this._optional; }
162+
get optional(): boolean {
163+
return this._optional;
164+
}
161165
set optional(value: boolean) {
162166
this._optional = coerceBooleanProperty(value);
163167
}
@@ -166,12 +170,12 @@ export class CdkStep implements OnChanges {
166170
/** Whether step is marked as completed. */
167171
@Input()
168172
get completed(): boolean {
169-
return this._customCompleted == null ? this._getDefaultCompleted() : this._customCompleted;
173+
return this._completedOverride == null ? this._getDefaultCompleted() : this._completedOverride;
170174
}
171175
set completed(value: boolean) {
172-
this._customCompleted = coerceBooleanProperty(value);
176+
this._completedOverride = coerceBooleanProperty(value);
173177
}
174-
private _customCompleted: boolean | null = null;
178+
_completedOverride: boolean|null = null;
175179

176180
private _getDefaultCompleted() {
177181
return this.stepControl ? this.stepControl.valid && this.interacted : this.interacted;
@@ -185,16 +189,16 @@ export class CdkStep implements OnChanges {
185189
set hasError(value: boolean) {
186190
this._customError = coerceBooleanProperty(value);
187191
}
188-
private _customError: boolean | null = null;
192+
private _customError: boolean|null = null;
189193

190194
private _getDefaultError() {
191195
return this.stepControl && this.stepControl.invalid && this.interacted;
192196
}
193197

194198
/** @breaking-change 8.0.0 remove the `?` after `stepperOptions` */
195199
constructor(
196-
@Inject(forwardRef(() => CdkStepper)) private _stepper: CdkStepper,
197-
@Optional() @Inject(STEPPER_GLOBAL_OPTIONS) stepperOptions?: StepperOptions) {
200+
@Inject(forwardRef(() => CdkStepper)) private _stepper: CdkStepper,
201+
@Optional() @Inject(STEPPER_GLOBAL_OPTIONS) stepperOptions?: StepperOptions) {
198202
this._stepperOptions = stepperOptions ? stepperOptions : {};
199203
this._displayDefaultIndicatorType = this._stepperOptions.displayDefaultIndicatorType !== false;
200204
this._showError = !!this._stepperOptions.showError;
@@ -209,8 +213,8 @@ export class CdkStep implements OnChanges {
209213
reset(): void {
210214
this.interacted = false;
211215

212-
if (this._customCompleted != null) {
213-
this._customCompleted = false;
216+
if (this._completedOverride != null) {
217+
this._completedOverride = false;
214218
}
215219

216220
if (this._customError != null) {
@@ -244,7 +248,7 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
244248
* @breaking-change 8.0.0 Remove `| undefined` once the `_document`
245249
* constructor param is required.
246250
*/
247-
private _document: Document | undefined;
251+
private _document: Document|undefined;
248252

249253
/**
250254
* The list of step components that the stepper is holding.
@@ -254,7 +258,7 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
254258
@ContentChildren(CdkStep) _steps: QueryList<CdkStep>;
255259

256260
/** The list of step components that the stepper is holding. */
257-
get steps(): QueryList<CdkStep> {
261+
get steps(): QueryList<CdkStep> {
258262
return this._steps;
259263
}
260264

@@ -267,13 +271,19 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
267271

268272
/** Whether the validity of previous steps should be checked or not. */
269273
@Input()
270-
get linear(): boolean { return this._linear; }
271-
set linear(value: boolean) { this._linear = coerceBooleanProperty(value); }
274+
get linear(): boolean {
275+
return this._linear;
276+
}
277+
set linear(value: boolean) {
278+
this._linear = coerceBooleanProperty(value);
279+
}
272280
private _linear = false;
273281

274282
/** The index of the selected step. */
275283
@Input()
276-
get selectedIndex() { return this._selectedIndex; }
284+
get selectedIndex() {
285+
return this._selectedIndex;
286+
}
277287
set selectedIndex(index: number) {
278288
const newIndex = coerceNumberProperty(index);
279289

@@ -283,8 +293,7 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
283293
throw Error('cdkStepper: Cannot assign out-of-bounds value to `selectedIndex`.');
284294
}
285295

286-
if (this._selectedIndex != newIndex &&
287-
!this._anyControlsInvalidOrPending(newIndex) &&
296+
if (this._selectedIndex != newIndex && !this._anyControlsInvalidOrPending(newIndex) &&
288297
(newIndex >= this._selectedIndex || this.steps.toArray()[newIndex].editable)) {
289298
this._updateSelectedItemIndex(index);
290299
}
@@ -305,20 +314,18 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
305314
}
306315

307316
/** Event emitted when the selected step has changed. */
308-
@Output() selectionChange: EventEmitter<StepperSelectionEvent>
309-
= new EventEmitter<StepperSelectionEvent>();
317+
@Output()
318+
selectionChange: EventEmitter<StepperSelectionEvent> = new EventEmitter<StepperSelectionEvent>();
310319

311320
/** Used to track unique ID for each stepper component. */
312321
_groupId: number;
313322

314323
protected _orientation: StepperOrientation = 'horizontal';
315324

316325
constructor(
317-
@Optional() private _dir: Directionality,
318-
private _changeDetectorRef: ChangeDetectorRef,
319-
// @breaking-change 8.0.0 `_elementRef` and `_document` parameters to become required.
320-
private _elementRef?: ElementRef<HTMLElement>,
321-
@Inject(DOCUMENT) _document?: any) {
326+
@Optional() private _dir: Directionality, private _changeDetectorRef: ChangeDetectorRef,
327+
// @breaking-change 8.0.0 `_elementRef` and `_document` parameters to become required.
328+
private _elementRef?: ElementRef<HTMLElement>, @Inject(DOCUMENT) _document?: any) {
322329
this._groupId = nextId++;
323330
this._document = _document;
324331
}
@@ -328,12 +335,12 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
328335
// extend this one might have them as view chidren. We initialize the keyboard handling in
329336
// AfterViewInit so we're guaranteed for both view and content children to be defined.
330337
this._keyManager = new FocusKeyManager<FocusableOption>(this._stepHeader)
331-
.withWrap()
332-
.withVerticalOrientation(this._orientation === 'vertical');
338+
.withWrap()
339+
.withVerticalOrientation(this._orientation === 'vertical');
333340

334-
(this._dir ? this._dir.change as Observable<Direction> : obaservableOf<Direction>())
335-
.pipe(startWith(this._layoutDirection()), takeUntil(this._destroyed))
336-
.subscribe(direction => this._keyManager.withHorizontalOrientation(direction));
341+
(this._dir ? (this._dir.change as Observable<Direction>) : obaservableOf<Direction>())
342+
.pipe(startWith(this._layoutDirection()), takeUntil(this._destroyed))
343+
.subscribe(direction => this._keyManager.withHorizontalOrientation(direction));
337344

338345
this._keyManager.updateActiveItemIndex(this._selectedIndex);
339346

@@ -397,9 +404,8 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
397404
const step = this.steps.toArray()[index];
398405
const isCurrentStep = this._isCurrentStep(index);
399406

400-
return step._displayDefaultIndicatorType
401-
? this._getDefaultIndicatorLogic(step, isCurrentStep)
402-
: this._getGuidelineLogic(step, isCurrentStep, state);
407+
return step._displayDefaultIndicatorType ? this._getDefaultIndicatorLogic(step, isCurrentStep) :
408+
this._getGuidelineLogic(step, isCurrentStep, state);
403409
}
404410

405411
private _getDefaultIndicatorLogic(step: CdkStep, isCurrentStep: boolean): StepState {
@@ -413,9 +419,7 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
413419
}
414420

415421
private _getGuidelineLogic(
416-
step: CdkStep,
417-
isCurrentStep: boolean,
418-
state: StepState = STEP_STATE.NUMBER): StepState {
422+
step: CdkStep, isCurrentStep: boolean, state: StepState = STEP_STATE.NUMBER): StepState {
419423
if (step._showError && step.hasError && !isCurrentStep) {
420424
return STEP_STATE.ERROR;
421425
} else if (step.completed && !isCurrentStep) {
@@ -486,10 +490,9 @@ export class CdkStepper implements AfterViewInit, OnDestroy {
486490
if (this._linear && index >= 0) {
487491
return steps.slice(0, index).some(step => {
488492
const control = step.stepControl;
489-
const isIncomplete = control ?
490-
(control.invalid || control.pending || !step.interacted) :
491-
!step.completed;
492-
return isIncomplete && !step.optional;
493+
const isIncomplete =
494+
control ? (control.invalid || control.pending || !step.interacted) : !step.completed;
495+
return isIncomplete && !step.optional && !step._completedOverride;
493496
});
494497
}
495498

src/material/stepper/stepper.spec.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -731,6 +731,27 @@ describe('MatStepper', () => {
731731
expect(steps[2].completed).toBe(true,
732732
'Expected third step to be considered complete when doing a run after a reset.');
733733
});
734+
735+
it('should be able to skip past the current step if a custom `completed` value is set', () => {
736+
expect(testComponent.oneGroup.get('oneCtrl')!.value).toBe('');
737+
expect(testComponent.oneGroup.get('oneCtrl')!.valid).toBe(false);
738+
expect(testComponent.oneGroup.valid).toBe(false);
739+
expect(stepperComponent.selectedIndex).toBe(0);
740+
741+
const nextButtonNativeEl = fixture.debugElement
742+
.queryAll(By.directive(MatStepperNext))[0].nativeElement;
743+
nextButtonNativeEl.click();
744+
fixture.detectChanges();
745+
746+
expect(stepperComponent.selectedIndex).toBe(0);
747+
748+
stepperComponent.steps.first.completed = true;
749+
nextButtonNativeEl.click();
750+
fixture.detectChanges();
751+
752+
expect(testComponent.oneGroup.valid).toBe(false);
753+
expect(stepperComponent.selectedIndex).toBe(1);
754+
});
734755
});
735756

736757
describe('linear stepper with a pre-defined selectedIndex', () => {

tools/public_api_guard/cdk/stepper.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export declare class CdkStep implements OnChanges {
2+
_completedOverride: boolean | null;
23
_displayDefaultIndicatorType: boolean;
34
_showError: boolean;
45
ariaLabel: string;

0 commit comments

Comments
 (0)