Skip to content

Commit 52f39bc

Browse files
authored
feat(material/stepper): allow for content to be rendered lazily (#15817)
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 510672e commit 52f39bc

File tree

12 files changed

+186
-10
lines changed

12 files changed

+186
-10
lines changed

src/components-examples/material/stepper/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {StepperOverviewExample} from './stepper-overview/stepper-overview-exampl
1414
import {StepperStatesExample} from './stepper-states/stepper-states-example';
1515
import {StepperVerticalExample} from './stepper-vertical/stepper-vertical-example';
1616
import {StepperHarnessExample} from './stepper-harness/stepper-harness-example';
17+
import {StepperLazyContentExample} from './stepper-lazy-content/stepper-lazy-content-example';
1718

1819
export {
1920
StepperEditableExample,
@@ -24,6 +25,7 @@ export {
2425
StepperOverviewExample,
2526
StepperStatesExample,
2627
StepperVerticalExample,
28+
StepperLazyContentExample,
2729
};
2830

2931
const EXAMPLES = [
@@ -35,6 +37,7 @@ const EXAMPLES = [
3537
StepperOverviewExample,
3638
StepperStatesExample,
3739
StepperVerticalExample,
40+
StepperLazyContentExample,
3841
];
3942

4043
@NgModule({
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
@@ -180,6 +180,13 @@ will not affect steppers marked as `linear`.
180180

181181
<!-- example(stepper-errors) -->
182182

183+
### Lazy rendering
184+
By default, the stepper will render all of it's content when it's initialized. If you have some
185+
content that you want to want to defer until the particular step is opened, you can put it inside
186+
an `ng-template` with the `matStepContent` attribute.
187+
188+
<!-- example(stepper-lazy-content) -->
189+
183190
### Keyboard interaction
184191
- <kbd>LEFT_ARROW</kbd>: Focuses the previous step header
185192
- <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
@@ -1302,6 +1302,44 @@ describe('MatStepper', () => {
13021302
expect(stepper.selectedIndex).toBe(1);
13031303
expect(stepper.selected).toBeTruthy();
13041304
});
1305+
1306+
describe('stepper with lazy content', () => {
1307+
it('should render the content of the selected step on init', () => {
1308+
const fixture = createComponent(StepperWithLazyContent);
1309+
const element = fixture.nativeElement;
1310+
fixture.componentInstance.selectedIndex = 1;
1311+
fixture.detectChanges();
1312+
1313+
expect(element.textContent).not.toContain('Step 1 content');
1314+
expect(element.textContent).toContain('Step 2 content');
1315+
expect(element.textContent).not.toContain('Step 3 content');
1316+
});
1317+
1318+
it('should render the content of steps when the user navigates to them', () => {
1319+
const fixture = createComponent(StepperWithLazyContent);
1320+
const element = fixture.nativeElement;
1321+
fixture.componentInstance.selectedIndex = 0;
1322+
fixture.detectChanges();
1323+
1324+
expect(element.textContent).toContain('Step 1 content');
1325+
expect(element.textContent).not.toContain('Step 2 content');
1326+
expect(element.textContent).not.toContain('Step 3 content');
1327+
1328+
fixture.componentInstance.selectedIndex = 1;
1329+
fixture.detectChanges();
1330+
1331+
expect(element.textContent).toContain('Step 1 content');
1332+
expect(element.textContent).toContain('Step 2 content');
1333+
expect(element.textContent).not.toContain('Step 3 content');
1334+
1335+
fixture.componentInstance.selectedIndex = 2;
1336+
fixture.detectChanges();
1337+
1338+
expect(element.textContent).toContain('Step 1 content');
1339+
expect(element.textContent).toContain('Step 2 content');
1340+
expect(element.textContent).toContain('Step 3 content');
1341+
});
1342+
});
13051343
});
13061344

13071345
/** Asserts that keyboard interaction works correctly. */
@@ -1826,3 +1864,26 @@ class NestedSteppers {
18261864
class StepperWithStaticOutOfBoundsIndex {
18271865
@ViewChild(MatStepper) stepper: MatStepper;
18281866
}
1867+
1868+
1869+
@Component({
1870+
template: `
1871+
<mat-vertical-stepper [selectedIndex]="selectedIndex">
1872+
<mat-step>
1873+
<ng-template matStepLabel>Step 1</ng-template>
1874+
<ng-template matStepContent>Step 1 content</ng-template>
1875+
</mat-step>
1876+
<mat-step>
1877+
<ng-template matStepLabel>Step 2</ng-template>
1878+
<ng-template matStepContent>Step 2 content</ng-template>
1879+
</mat-step>
1880+
<mat-step>
1881+
<ng-template matStepLabel>Step 3</ng-template>
1882+
<ng-template matStepContent>Step 3 content</ng-template>
1883+
</mat-step>
1884+
</mat-vertical-stepper>
1885+
`
1886+
})
1887+
class StepperWithLazyContent {
1888+
selectedIndex = 0;
1889+
}

src/material/stepper/stepper.ts

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

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

5155
@Component({
5256
selector: 'mat-step',
@@ -59,20 +63,50 @@ import {MatStepperIcon, MatStepperIconContext} from './stepper-icon';
5963
exportAs: 'matStep',
6064
changeDetection: ChangeDetectionStrategy.OnPush,
6165
})
62-
export class MatStep extends CdkStep implements ErrorStateMatcher {
66+
export class MatStep extends CdkStep implements ErrorStateMatcher, AfterContentInit, OnDestroy {
67+
private _isSelected = Subscription.EMPTY;
68+
6369
/** Content for step label given by `<ng-template matStepLabel>`. */
6470
@ContentChild(MatStepLabel) stepLabel: MatStepLabel;
6571

6672
/** Theme color for the particular step. */
6773
@Input() color: ThemePalette;
6874

75+
/** Content that will be rendered lazily. */
76+
@ContentChild(MatStepContent, {static: false}) _lazyContent: MatStepContent;
77+
78+
/** Currently-attached portal containing the lazy content. */
79+
_portal: TemplatePortal;
80+
6981
/** @breaking-change 8.0.0 remove the `?` after `stepperOptions` */
82+
/** @breaking-change 9.0.0 _viewContainerRef parameter to become required. */
7083
constructor(@Inject(forwardRef(() => MatStepper)) stepper: MatStepper,
7184
@SkipSelf() private _errorStateMatcher: ErrorStateMatcher,
72-
@Optional() @Inject(STEPPER_GLOBAL_OPTIONS) stepperOptions?: StepperOptions) {
85+
@Optional() @Inject(STEPPER_GLOBAL_OPTIONS) stepperOptions?: StepperOptions,
86+
private _viewContainerRef?: ViewContainerRef) {
7387
super(stepper, stepperOptions);
7488
}
7589

90+
ngAfterContentInit() {
91+
/** @breaking-change 9.0.0 Null check for _viewContainerRef to be removed. */
92+
if (this._viewContainerRef) {
93+
this._isSelected = this._stepper.steps.changes.pipe(switchMap(() => {
94+
return this._stepper.selectionChange.pipe(
95+
map(event => event.selectedStep === this),
96+
startWith(this._stepper.selected === this)
97+
);
98+
})).subscribe(isSelected => {
99+
if (isSelected && this._lazyContent && !this._portal) {
100+
this._portal = new TemplatePortal(this._lazyContent._template, this._viewContainerRef!);
101+
}
102+
});
103+
}
104+
}
105+
106+
ngOnDestroy() {
107+
this._isSelected.unsubscribe();
108+
}
109+
76110
/** Custom error state matcher that additionally checks for validity of interacted form. */
77111
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
78112
const originalErrorState = this._errorStateMatcher.isErrorState(control, form);

tools/public_api_guard/material/stepper.d.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,24 @@ export declare class MatHorizontalStepper extends MatStepper {
1616
static ɵfac: i0.ɵɵFactoryDef<MatHorizontalStepper, never>;
1717
}
1818

19-
export declare class MatStep extends CdkStep implements ErrorStateMatcher {
19+
export declare class MatStep extends CdkStep implements ErrorStateMatcher, AfterContentInit, OnDestroy {
20+
_lazyContent: MatStepContent;
21+
_portal: TemplatePortal;
2022
color: ThemePalette;
2123
stepLabel: MatStepLabel;
22-
constructor(stepper: MatStepper, _errorStateMatcher: ErrorStateMatcher, stepperOptions?: StepperOptions);
24+
constructor(stepper: MatStepper, _errorStateMatcher: ErrorStateMatcher, stepperOptions?: StepperOptions, _viewContainerRef?: ViewContainerRef | undefined);
2325
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean;
24-
static ɵcmp: i0.ɵɵComponentDefWithMeta<MatStep, "mat-step", ["matStep"], { "color": "color"; }, {}, ["stepLabel"], ["*"]>;
25-
static ɵfac: i0.ɵɵFactoryDef<MatStep, [null, { skipSelf: true; }, { optional: true; }]>;
26+
ngAfterContentInit(): void;
27+
ngOnDestroy(): void;
28+
static ɵcmp: i0.ɵɵComponentDefWithMeta<MatStep, "mat-step", ["matStep"], { "color": "color"; }, {}, ["stepLabel", "_lazyContent"], ["*"]>;
29+
static ɵfac: i0.ɵɵFactoryDef<MatStep, [null, { skipSelf: true; }, { optional: true; }, null]>;
30+
}
31+
32+
export declare class MatStepContent {
33+
_template: TemplateRef<any>;
34+
constructor(_template: TemplateRef<any>);
35+
static ɵdir: i0.ɵɵDirectiveDefWithMeta<MatStepContent, "ng-template[matStepContent]", never, {}, {}, never>;
36+
static ɵfac: i0.ɵɵFactoryDef<MatStepContent, never>;
2637
}
2738

2839
export declare class MatStepHeader extends _MatStepHeaderMixinBase implements AfterViewInit, OnDestroy, CanColor {
@@ -105,7 +116,7 @@ export declare class MatStepperIntl {
105116

106117
export declare class MatStepperModule {
107118
static ɵinj: i0.ɵɵInjectorDef<MatStepperModule>;
108-
static ɵmod: i0.ɵɵNgModuleDefWithMeta<MatStepperModule, [typeof i1.MatHorizontalStepper, typeof i1.MatVerticalStepper, typeof i1.MatStep, typeof i2.MatStepLabel, typeof i1.MatStepper, typeof i3.MatStepperNext, typeof i3.MatStepperPrevious, typeof i4.MatStepHeader, typeof i5.MatStepperIcon], [typeof i6.MatCommonModule, typeof i7.CommonModule, typeof i8.PortalModule, typeof i9.MatButtonModule, typeof i10.CdkStepperModule, typeof i11.MatIconModule, typeof i6.MatRippleModule], [typeof i6.MatCommonModule, typeof i1.MatHorizontalStepper, typeof i1.MatVerticalStepper, typeof i1.MatStep, typeof i2.MatStepLabel, typeof i1.MatStepper, typeof i3.MatStepperNext, typeof i3.MatStepperPrevious, typeof i4.MatStepHeader, typeof i5.MatStepperIcon]>;
119+
static ɵmod: i0.ɵɵNgModuleDefWithMeta<MatStepperModule, [typeof i1.MatHorizontalStepper, typeof i1.MatVerticalStepper, typeof i1.MatStep, typeof i2.MatStepLabel, typeof i1.MatStepper, typeof i3.MatStepperNext, typeof i3.MatStepperPrevious, typeof i4.MatStepHeader, typeof i5.MatStepperIcon, typeof i6.MatStepContent], [typeof i7.MatCommonModule, typeof i8.CommonModule, typeof i9.PortalModule, typeof i10.MatButtonModule, typeof i11.CdkStepperModule, typeof i12.MatIconModule, typeof i7.MatRippleModule], [typeof i7.MatCommonModule, typeof i1.MatHorizontalStepper, typeof i1.MatVerticalStepper, typeof i1.MatStep, typeof i2.MatStepLabel, typeof i1.MatStepper, typeof i3.MatStepperNext, typeof i3.MatStepperPrevious, typeof i4.MatStepHeader, typeof i5.MatStepperIcon, typeof i6.MatStepContent]>;
109120
}
110121

111122
export declare class MatStepperNext extends CdkStepperNext {

0 commit comments

Comments
 (0)