Skip to content

Commit 9ac9a24

Browse files
committed
feat(stepper): allow for content to be rendered lazily
Adds the `matStepContent` directive that allows consumers to defer rendering the content of a step until it is opened for the first time. Fixes #12339.
1 parent 8e321ae commit 9ac9a24

File tree

13 files changed

+178
-8
lines changed

13 files changed

+178
-8
lines changed

src/cdk/stepper/stepper.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ export class CdkStep implements OnChanges {
193193

194194
/** @breaking-change 8.0.0 remove the `?` after `stepperOptions` */
195195
constructor(
196-
@Inject(forwardRef(() => CdkStepper)) private _stepper: CdkStepper,
196+
@Inject(forwardRef(() => CdkStepper)) protected _stepper: CdkStepper,
197197
@Optional() @Inject(STEPPER_GLOBAL_OPTIONS) stepperOptions?: StepperOptions) {
198198
this._stepperOptions = stepperOptions ? stepperOptions : {};
199199
this._displayDefaultIndicatorType = this._stepperOptions.displayDefaultIndicatorType !== false;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/** No CSS for this example */
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<mat-vertical-stepper>
2+
<mat-step>
3+
<ng-template matStepLabel>Step 1</ng-template>
4+
<ng-template matStepContent>
5+
<p>This content was rendered lazily</p>
6+
<button mat-button matStepperNext>Next</button>
7+
</ng-template>
8+
</mat-step>
9+
<mat-step>
10+
<ng-template matStepLabel>Step 2</ng-template>
11+
<ng-template matStepContent>
12+
<p>This content was also rendered lazily</p>
13+
<button mat-button matStepperPrevious>Back</button>
14+
<button mat-button matStepperNext>Next</button>
15+
</ng-template>
16+
</mat-step>
17+
<mat-step>
18+
<ng-template matStepLabel>Step 3</ng-template>
19+
<p>This content was rendered eagerly</p>
20+
<button mat-button matStepperPrevious>Back</button>
21+
</mat-step>
22+
</mat-vertical-stepper>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import {Component} from '@angular/core';
2+
3+
/**
4+
* @title Stepper lazy content rendering
5+
*/
6+
@Component({
7+
selector: 'stepper-lazy-content-example',
8+
templateUrl: 'stepper-lazy-content-example.html',
9+
styleUrls: ['stepper-lazy-content-example.css'],
10+
})
11+
export class StepperLazyContentExample {}

src/material/stepper/public-api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ export * from './step-header';
1414
export * from './stepper-intl';
1515
export * from './stepper-animations';
1616
export * from './stepper-icon';
17+
export * from './step-content';

src/material/stepper/step-content.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Directive, TemplateRef} from '@angular/core';
10+
11+
/**
12+
* Content for a `mat-step` that will be rendered lazily.
13+
*/
14+
@Directive({
15+
selector: 'ng-template[matStepContent]'
16+
})
17+
export class MatStepContent {
18+
constructor(public _template: TemplateRef<any>) {}
19+
}

src/material/stepper/step.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1-
<ng-template><ng-content></ng-content></ng-template>
1+
<ng-template>
2+
<ng-content></ng-content>
3+
<ng-template [cdkPortalOutlet]="_portal"></ng-template>
4+
</ng-template>

src/material/stepper/stepper-module.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {MatHorizontalStepper, MatStep, MatStepper, MatVerticalStepper} from './s
1919
import {MatStepperNext, MatStepperPrevious} from './stepper-button';
2020
import {MatStepperIcon} from './stepper-icon';
2121
import {MAT_STEPPER_INTL_PROVIDER} from './stepper-intl';
22+
import {MatStepContent} from './step-content';
2223

2324

2425
@NgModule({
@@ -42,6 +43,7 @@ import {MAT_STEPPER_INTL_PROVIDER} from './stepper-intl';
4243
MatStepperPrevious,
4344
MatStepHeader,
4445
MatStepperIcon,
46+
MatStepContent,
4547
],
4648
declarations: [
4749
MatHorizontalStepper,
@@ -53,6 +55,7 @@ import {MAT_STEPPER_INTL_PROVIDER} from './stepper-intl';
5355
MatStepperPrevious,
5456
MatStepHeader,
5557
MatStepperIcon,
58+
MatStepContent,
5659
],
5760
providers: [MAT_STEPPER_INTL_PROVIDER, ErrorStateMatcher],
5861
})

src/material/stepper/stepper.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,13 @@ The stepper can now show error states by simply providing the `showError` option
231231

232232
<!-- example(stepper-errors) -->
233233

234+
### Lazy rendering
235+
By default, the stepper will render all of it's content when it's initialized. If you have some
236+
content that you want to want to defer until the particular step is opened, you can put it inside
237+
an `ng-template` with the `matStepContent` attribute.
238+
239+
<!-- example(stepper-lazy-content) -->
240+
234241
### Keyboard interaction
235242
- <kbd>LEFT_ARROW</kbd>: Focuses the previous step header
236243
- <kbd>RIGHT_ARROW</kbd>: Focuses the next step header

src/material/stepper/stepper.spec.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1064,6 +1064,44 @@ describe('MatStepper', () => {
10641064
expect(stepper._getIndicatorType(1)).toBe(STEP_STATE.EDIT);
10651065
});
10661066
});
1067+
1068+
describe('stepper with lazy content', () => {
1069+
it('should render the content of the selected step on init', () => {
1070+
const fixture = createComponent(StepperWithLazyContent);
1071+
const element = fixture.nativeElement;
1072+
fixture.componentInstance.selectedIndex = 1;
1073+
fixture.detectChanges();
1074+
1075+
expect(element.textContent).not.toContain('Step 1 content');
1076+
expect(element.textContent).toContain('Step 2 content');
1077+
expect(element.textContent).not.toContain('Step 3 content');
1078+
});
1079+
1080+
it('should render the content of steps when the user navigates to them', () => {
1081+
const fixture = createComponent(StepperWithLazyContent);
1082+
const element = fixture.nativeElement;
1083+
fixture.componentInstance.selectedIndex = 0;
1084+
fixture.detectChanges();
1085+
1086+
expect(element.textContent).toContain('Step 1 content');
1087+
expect(element.textContent).not.toContain('Step 2 content');
1088+
expect(element.textContent).not.toContain('Step 3 content');
1089+
1090+
fixture.componentInstance.selectedIndex = 1;
1091+
fixture.detectChanges();
1092+
1093+
expect(element.textContent).toContain('Step 1 content');
1094+
expect(element.textContent).toContain('Step 2 content');
1095+
expect(element.textContent).not.toContain('Step 3 content');
1096+
1097+
fixture.componentInstance.selectedIndex = 2;
1098+
fixture.detectChanges();
1099+
1100+
expect(element.textContent).toContain('Step 1 content');
1101+
expect(element.textContent).toContain('Step 2 content');
1102+
expect(element.textContent).toContain('Step 3 content');
1103+
});
1104+
});
10671105
});
10681106

10691107
/** Asserts that keyboard interaction works correctly. */
@@ -1498,3 +1536,26 @@ class StepperWithAriaInputs {
14981536
ariaLabel: string;
14991537
ariaLabelledby: string;
15001538
}
1539+
1540+
@Component({
1541+
template: `
1542+
<mat-vertical-stepper [selectedIndex]="selectedIndex">
1543+
<mat-step>
1544+
<ng-template matStepLabel>Step 1</ng-template>
1545+
<ng-template matStepContent>Step 1 content</ng-template>
1546+
</mat-step>
1547+
<mat-step>
1548+
<ng-template matStepLabel>Step 2</ng-template>
1549+
<ng-template matStepContent>Step 2 content</ng-template>
1550+
</mat-step>
1551+
<mat-step>
1552+
<ng-template matStepLabel>Step 3</ng-template>
1553+
<ng-template matStepContent>Step 3 content</ng-template>
1554+
</mat-step>
1555+
</mat-vertical-stepper>
1556+
`
1557+
})
1558+
class StepperWithLazyContent {
1559+
selectedIndex = 0;
1560+
}
1561+

src/material/stepper/stepper.ts

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,24 +28,28 @@ import {
2828
forwardRef,
2929
Inject,
3030
Input,
31+
OnDestroy,
3132
Optional,
3233
Output,
3334
QueryList,
3435
SkipSelf,
3536
TemplateRef,
3637
ViewChildren,
38+
ViewContainerRef,
3739
ViewEncapsulation,
3840
} from '@angular/core';
3941
import {FormControl, FormGroupDirective, NgForm} from '@angular/forms';
4042
import {DOCUMENT} from '@angular/common';
4143
import {ErrorStateMatcher} from '@angular/material/core';
42-
import {Subject} from 'rxjs';
43-
import {takeUntil, distinctUntilChanged} from 'rxjs/operators';
44+
import {TemplatePortal} from '@angular/cdk/portal';
45+
import {Subject, Subscription} from 'rxjs';
46+
import {takeUntil, distinctUntilChanged, map, startWith} from 'rxjs/operators';
4447

4548
import {MatStepHeader} from './step-header';
4649
import {MatStepLabel} from './step-label';
4750
import {matStepperAnimations} from './stepper-animations';
4851
import {MatStepperIcon, MatStepperIconContext} from './stepper-icon';
52+
import {MatStepContent} from './step-content';
4953

5054
@Component({
5155
moduleId: module.id,
@@ -56,17 +60,45 @@ import {MatStepperIcon, MatStepperIconContext} from './stepper-icon';
5660
exportAs: 'matStep',
5761
changeDetection: ChangeDetectionStrategy.OnPush,
5862
})
59-
export class MatStep extends CdkStep implements ErrorStateMatcher {
63+
export class MatStep extends CdkStep implements ErrorStateMatcher, AfterContentInit, OnDestroy {
64+
private _isSelected = Subscription.EMPTY;
65+
6066
/** Content for step label given by `<ng-template matStepLabel>`. */
6167
@ContentChild(MatStepLabel, {static: false}) stepLabel: MatStepLabel;
6268

69+
/** Content that will be rendered lazily. */
70+
@ContentChild(MatStepContent, {static: false}) _lazyContent: MatStepContent;
71+
72+
/** Currently-attached portal containing the lazy content. */
73+
_portal: TemplatePortal;
74+
6375
/** @breaking-change 8.0.0 remove the `?` after `stepperOptions` */
76+
/** @breaking-change 9.0.0 _viewContainerRef parameter to become required. */
6477
constructor(@Inject(forwardRef(() => MatStepper)) stepper: MatStepper,
6578
@SkipSelf() private _errorStateMatcher: ErrorStateMatcher,
66-
@Optional() @Inject(STEPPER_GLOBAL_OPTIONS) stepperOptions?: StepperOptions) {
79+
@Optional() @Inject(STEPPER_GLOBAL_OPTIONS) stepperOptions?: StepperOptions,
80+
private _viewContainerRef?: ViewContainerRef) {
6781
super(stepper, stepperOptions);
6882
}
6983

84+
ngAfterContentInit() {
85+
/** @breaking-change 9.0.0 Null check for _viewContainerRef to be removed. */
86+
if (this._viewContainerRef) {
87+
this._isSelected = this._stepper.selectionChange.pipe(
88+
map(event => event.selectedStep === this),
89+
startWith(this._stepper.selected === this)
90+
).subscribe(isSelected => {
91+
if (isSelected && this._lazyContent && !this._portal) {
92+
this._portal = new TemplatePortal(this._lazyContent._template, this._viewContainerRef!);
93+
}
94+
});
95+
}
96+
}
97+
98+
ngOnDestroy() {
99+
this._isSelected.unsubscribe();
100+
}
101+
70102
/** Custom error state matcher that additionally checks for validity of interacted form. */
71103
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
72104
const originalErrorState = this._errorStateMatcher.isErrorState(control, form);

tools/public_api_guard/cdk/stepper.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export declare class CdkStep implements OnChanges {
22
_displayDefaultIndicatorType: boolean;
33
_showError: boolean;
4+
protected _stepper: CdkStepper;
45
ariaLabel: string;
56
ariaLabelledby: string;
67
completed: boolean;

tools/public_api_guard/material/stepper.d.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,19 @@ export declare class MatHorizontalStepper extends MatStepper {
1010
labelPosition: 'bottom' | 'end';
1111
}
1212

13-
export declare class MatStep extends CdkStep implements ErrorStateMatcher {
13+
export declare class MatStep extends CdkStep implements ErrorStateMatcher, AfterContentInit, OnDestroy {
14+
_lazyContent: MatStepContent;
15+
_portal: TemplatePortal;
1416
stepLabel: MatStepLabel;
15-
constructor(stepper: MatStepper, _errorStateMatcher: ErrorStateMatcher, stepperOptions?: StepperOptions);
17+
constructor(stepper: MatStepper, _errorStateMatcher: ErrorStateMatcher, stepperOptions?: StepperOptions, _viewContainerRef?: ViewContainerRef | undefined);
1618
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean;
19+
ngAfterContentInit(): void;
20+
ngOnDestroy(): void;
21+
}
22+
23+
export declare class MatStepContent {
24+
_template: TemplateRef<any>;
25+
constructor(_template: TemplateRef<any>);
1726
}
1827

1928
export declare class MatStepHeader extends CdkStepHeader implements OnDestroy {

0 commit comments

Comments
 (0)