Skip to content

Commit 9b4c503

Browse files
authored
feat(cdk/stepper): emit event when the user interacts with a step (#22400)
Adds an `interacted` event that will emit when the user tries to move away from a step. Fixes #19918.
1 parent 6408731 commit 9b4c503

File tree

3 files changed

+43
-10
lines changed

3 files changed

+43
-10
lines changed

src/cdk/stepper/stepper.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,13 @@ export class CdkStep implements OnChanges {
124124
/** The top level abstract control of the step. */
125125
@Input() stepControl: AbstractControlLike;
126126

127-
/** Whether user has seen the expanded step content or not. */
127+
/** Whether user has attempted to move away from the step. */
128128
interacted = false;
129129

130+
/** Emits when the user has attempted to move away from the step. */
131+
@Output('interacted')
132+
readonly interactedStream: EventEmitter<CdkStep> = new EventEmitter<CdkStep>();
133+
130134
/** Plain text label of the step. */
131135
@Input() label: string;
132136

@@ -229,6 +233,13 @@ export class CdkStep implements OnChanges {
229233
this._stepper._stateChanged();
230234
}
231235

236+
_markAsInteracted() {
237+
if (!this.interacted) {
238+
this.interacted = true;
239+
this.interactedStream.emit(this);
240+
}
241+
}
242+
232243
static ngAcceptInputType_editable: BooleanInput;
233244
static ngAcceptInputType_hasError: BooleanInput;
234245
static ngAcceptInputType_optional: BooleanInput;
@@ -281,13 +292,7 @@ export class CdkStepper implements AfterContentInit, AfterViewInit, OnDestroy {
281292
throw Error('cdkStepper: Cannot assign out-of-bounds value to `selectedIndex`.');
282293
}
283294

284-
const selectedStep = this.selected;
285-
286-
if (selectedStep) {
287-
// TODO: this should really be called something like `visited` instead. Just because
288-
// the user has seen the step doesn't guarantee that they've interacted with it.
289-
selectedStep.interacted = true;
290-
}
295+
this.selected?._markAsInteracted();
291296

292297
if (this._selectedIndex !== newIndex && !this._anyControlsInvalidOrPending(newIndex) &&
293298
(newIndex >= this._selectedIndex || this.steps.toArray()[newIndex].editable)) {

src/material/stepper/stepper.spec.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ import {
4545
import {MatRipple, ThemePalette} from '@angular/material/core';
4646
import {By} from '@angular/platform-browser';
4747
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
48-
import {Observable, Subject} from 'rxjs';
48+
import {merge, Observable, Subject} from 'rxjs';
4949
import {map, take} from 'rxjs/operators';
5050
import {MatStepHeader, MatStepperModule} from './index';
5151
import {MatHorizontalStepper, MatStep, MatStepper, MatVerticalStepper} from './stepper';
@@ -1059,6 +1059,32 @@ describe('MatStepper', () => {
10591059
fixture.detectChanges();
10601060
expect(stepper.steps.map(step => step.interacted)).toEqual([true, true, true]);
10611061
});
1062+
1063+
it('should emit when the user has interacted with a step', () => {
1064+
const fixture = createComponent(SimpleMatHorizontalStepperApp);
1065+
fixture.detectChanges();
1066+
1067+
const stepper: MatStepper =
1068+
fixture.debugElement.query(By.directive(MatStepper)).componentInstance;
1069+
const interactedSteps: number[] = [];
1070+
const subscription = merge(...stepper.steps.map(step => step.interactedStream))
1071+
.subscribe(step => interactedSteps.push(stepper.steps.toArray().indexOf(step as MatStep)));
1072+
1073+
expect(interactedSteps).toEqual([]);
1074+
1075+
stepper.next();
1076+
fixture.detectChanges();
1077+
expect(interactedSteps).toEqual([0]);
1078+
1079+
stepper.next();
1080+
fixture.detectChanges();
1081+
expect(interactedSteps).toEqual([0, 1]);
1082+
1083+
stepper.next();
1084+
fixture.detectChanges();
1085+
expect(interactedSteps).toEqual([0, 1, 2]);
1086+
subscription.unsubscribe();
1087+
});
10621088
});
10631089

10641090
describe('linear stepper with valid step', () => {

tools/public_api_guard/cdk/stepper.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,21 +14,23 @@ export declare class CdkStep implements OnChanges {
1414
get hasError(): boolean;
1515
set hasError(value: boolean);
1616
interacted: boolean;
17+
readonly interactedStream: EventEmitter<CdkStep>;
1718
label: string;
1819
get optional(): boolean;
1920
set optional(value: boolean);
2021
state: StepState;
2122
stepControl: AbstractControlLike;
2223
stepLabel: CdkStepLabel;
2324
constructor(_stepper: CdkStepper, stepperOptions?: StepperOptions);
25+
_markAsInteracted(): void;
2426
ngOnChanges(): void;
2527
reset(): void;
2628
select(): void;
2729
static ngAcceptInputType_completed: BooleanInput;
2830
static ngAcceptInputType_editable: BooleanInput;
2931
static ngAcceptInputType_hasError: BooleanInput;
3032
static ngAcceptInputType_optional: BooleanInput;
31-
static ɵcmp: i0.ɵɵComponentDeclaration<CdkStep, "cdk-step", ["cdkStep"], { "stepControl": "stepControl"; "label": "label"; "errorMessage": "errorMessage"; "ariaLabel": "aria-label"; "ariaLabelledby": "aria-labelledby"; "state": "state"; "editable": "editable"; "optional": "optional"; "completed": "completed"; "hasError": "hasError"; }, {}, ["stepLabel"], ["*"]>;
33+
static ɵcmp: i0.ɵɵComponentDeclaration<CdkStep, "cdk-step", ["cdkStep"], { "stepControl": "stepControl"; "label": "label"; "errorMessage": "errorMessage"; "ariaLabel": "aria-label"; "ariaLabelledby": "aria-labelledby"; "state": "state"; "editable": "editable"; "optional": "optional"; "completed": "completed"; "hasError": "hasError"; }, { "interactedStream": "interacted"; }, ["stepLabel"], ["*"]>;
3234
static ɵfac: i0.ɵɵFactoryDeclaration<CdkStep, [null, { optional: true; }]>;
3335
}
3436

0 commit comments

Comments
 (0)