Skip to content

Commit

Permalink
feat(material/stepper): allow for content to be rendered lazily (#15817)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
crisbeto authored Feb 7, 2021
1 parent 510672e commit 52f39bc
Show file tree
Hide file tree
Showing 12 changed files with 186 additions and 10 deletions.
3 changes: 3 additions & 0 deletions src/components-examples/material/stepper/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {StepperOverviewExample} from './stepper-overview/stepper-overview-exampl
import {StepperStatesExample} from './stepper-states/stepper-states-example';
import {StepperVerticalExample} from './stepper-vertical/stepper-vertical-example';
import {StepperHarnessExample} from './stepper-harness/stepper-harness-example';
import {StepperLazyContentExample} from './stepper-lazy-content/stepper-lazy-content-example';

export {
StepperEditableExample,
Expand All @@ -24,6 +25,7 @@ export {
StepperOverviewExample,
StepperStatesExample,
StepperVerticalExample,
StepperLazyContentExample,
};

const EXAMPLES = [
Expand All @@ -35,6 +37,7 @@ const EXAMPLES = [
StepperOverviewExample,
StepperStatesExample,
StepperVerticalExample,
StepperLazyContentExample,
];

@NgModule({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/** No CSS for this example */
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<mat-vertical-stepper>
<mat-step>
<ng-template matStepLabel>Step 1</ng-template>
<ng-template matStepContent>
<p>This content was rendered lazily</p>
<button mat-button matStepperNext>Next</button>
</ng-template>
</mat-step>
<mat-step>
<ng-template matStepLabel>Step 2</ng-template>
<ng-template matStepContent>
<p>This content was also rendered lazily</p>
<button mat-button matStepperPrevious>Back</button>
<button mat-button matStepperNext>Next</button>
</ng-template>
</mat-step>
<mat-step>
<ng-template matStepLabel>Step 3</ng-template>
<p>This content was rendered eagerly</p>
<button mat-button matStepperPrevious>Back</button>
</mat-step>
</mat-vertical-stepper>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {Component} from '@angular/core';

/**
* @title Stepper lazy content rendering
*/
@Component({
selector: 'stepper-lazy-content-example',
templateUrl: 'stepper-lazy-content-example.html',
styleUrls: ['stepper-lazy-content-example.css'],
})
export class StepperLazyContentExample {}
1 change: 1 addition & 0 deletions src/material/stepper/public-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export * from './step-header';
export * from './stepper-intl';
export * from './stepper-animations';
export * from './stepper-icon';
export * from './step-content';
19 changes: 19 additions & 0 deletions src/material/stepper/step-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {Directive, TemplateRef} from '@angular/core';

/**
* Content for a `mat-step` that will be rendered lazily.
*/
@Directive({
selector: 'ng-template[matStepContent]'
})
export class MatStepContent {
constructor(public _template: TemplateRef<any>) {}
}
5 changes: 4 additions & 1 deletion src/material/stepper/step.html
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
<ng-template><ng-content></ng-content></ng-template>
<ng-template>
<ng-content></ng-content>
<ng-template [cdkPortalOutlet]="_portal"></ng-template>
</ng-template>
3 changes: 3 additions & 0 deletions src/material/stepper/stepper-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {MatHorizontalStepper, MatStep, MatStepper, MatVerticalStepper} from './s
import {MatStepperNext, MatStepperPrevious} from './stepper-button';
import {MatStepperIcon} from './stepper-icon';
import {MAT_STEPPER_INTL_PROVIDER} from './stepper-intl';
import {MatStepContent} from './step-content';


@NgModule({
Expand All @@ -42,6 +43,7 @@ import {MAT_STEPPER_INTL_PROVIDER} from './stepper-intl';
MatStepperPrevious,
MatStepHeader,
MatStepperIcon,
MatStepContent,
],
declarations: [
MatHorizontalStepper,
Expand All @@ -53,6 +55,7 @@ import {MAT_STEPPER_INTL_PROVIDER} from './stepper-intl';
MatStepperPrevious,
MatStepHeader,
MatStepperIcon,
MatStepContent,
],
providers: [MAT_STEPPER_INTL_PROVIDER, ErrorStateMatcher],
})
Expand Down
7 changes: 7 additions & 0 deletions src/material/stepper/stepper.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,13 @@ will not affect steppers marked as `linear`.

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

### Lazy rendering
By default, the stepper will render all of it's content when it's initialized. If you have some
content that you want to want to defer until the particular step is opened, you can put it inside
an `ng-template` with the `matStepContent` attribute.

<!-- example(stepper-lazy-content) -->

### Keyboard interaction
- <kbd>LEFT_ARROW</kbd>: Focuses the previous step header
- <kbd>RIGHT_ARROW</kbd>: Focuses the next step header
Expand Down
61 changes: 61 additions & 0 deletions src/material/stepper/stepper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1302,6 +1302,44 @@ describe('MatStepper', () => {
expect(stepper.selectedIndex).toBe(1);
expect(stepper.selected).toBeTruthy();
});

describe('stepper with lazy content', () => {
it('should render the content of the selected step on init', () => {
const fixture = createComponent(StepperWithLazyContent);
const element = fixture.nativeElement;
fixture.componentInstance.selectedIndex = 1;
fixture.detectChanges();

expect(element.textContent).not.toContain('Step 1 content');
expect(element.textContent).toContain('Step 2 content');
expect(element.textContent).not.toContain('Step 3 content');
});

it('should render the content of steps when the user navigates to them', () => {
const fixture = createComponent(StepperWithLazyContent);
const element = fixture.nativeElement;
fixture.componentInstance.selectedIndex = 0;
fixture.detectChanges();

expect(element.textContent).toContain('Step 1 content');
expect(element.textContent).not.toContain('Step 2 content');
expect(element.textContent).not.toContain('Step 3 content');

fixture.componentInstance.selectedIndex = 1;
fixture.detectChanges();

expect(element.textContent).toContain('Step 1 content');
expect(element.textContent).toContain('Step 2 content');
expect(element.textContent).not.toContain('Step 3 content');

fixture.componentInstance.selectedIndex = 2;
fixture.detectChanges();

expect(element.textContent).toContain('Step 1 content');
expect(element.textContent).toContain('Step 2 content');
expect(element.textContent).toContain('Step 3 content');
});
});
});

/** Asserts that keyboard interaction works correctly. */
Expand Down Expand Up @@ -1826,3 +1864,26 @@ class NestedSteppers {
class StepperWithStaticOutOfBoundsIndex {
@ViewChild(MatStepper) stepper: MatStepper;
}


@Component({
template: `
<mat-vertical-stepper [selectedIndex]="selectedIndex">
<mat-step>
<ng-template matStepLabel>Step 1</ng-template>
<ng-template matStepContent>Step 1 content</ng-template>
</mat-step>
<mat-step>
<ng-template matStepLabel>Step 2</ng-template>
<ng-template matStepContent>Step 2 content</ng-template>
</mat-step>
<mat-step>
<ng-template matStepLabel>Step 3</ng-template>
<ng-template matStepContent>Step 3 content</ng-template>
</mat-step>
</mat-vertical-stepper>
`
})
class StepperWithLazyContent {
selectedIndex = 0;
}
42 changes: 38 additions & 4 deletions src/material/stepper/stepper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,24 +29,28 @@ import {
forwardRef,
Inject,
Input,
OnDestroy,
Optional,
Output,
QueryList,
SkipSelf,
TemplateRef,
ViewChildren,
ViewContainerRef,
ViewEncapsulation,
} from '@angular/core';
import {FormControl, FormGroupDirective, NgForm} from '@angular/forms';
import {DOCUMENT} from '@angular/common';
import {ErrorStateMatcher, ThemePalette} from '@angular/material/core';
import {Subject} from 'rxjs';
import {takeUntil, distinctUntilChanged} from 'rxjs/operators';
import {TemplatePortal} from '@angular/cdk/portal';
import {Subject, Subscription} from 'rxjs';
import {takeUntil, distinctUntilChanged, map, startWith, switchMap} from 'rxjs/operators';

import {MatStepHeader} from './step-header';
import {MatStepLabel} from './step-label';
import {matStepperAnimations} from './stepper-animations';
import {MatStepperIcon, MatStepperIconContext} from './stepper-icon';
import {MatStepContent} from './step-content';

@Component({
selector: 'mat-step',
Expand All @@ -59,20 +63,50 @@ import {MatStepperIcon, MatStepperIconContext} from './stepper-icon';
exportAs: 'matStep',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatStep extends CdkStep implements ErrorStateMatcher {
export class MatStep extends CdkStep implements ErrorStateMatcher, AfterContentInit, OnDestroy {
private _isSelected = Subscription.EMPTY;

/** Content for step label given by `<ng-template matStepLabel>`. */
@ContentChild(MatStepLabel) stepLabel: MatStepLabel;

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

/** Content that will be rendered lazily. */
@ContentChild(MatStepContent, {static: false}) _lazyContent: MatStepContent;

/** Currently-attached portal containing the lazy content. */
_portal: TemplatePortal;

/** @breaking-change 8.0.0 remove the `?` after `stepperOptions` */
/** @breaking-change 9.0.0 _viewContainerRef parameter to become required. */
constructor(@Inject(forwardRef(() => MatStepper)) stepper: MatStepper,
@SkipSelf() private _errorStateMatcher: ErrorStateMatcher,
@Optional() @Inject(STEPPER_GLOBAL_OPTIONS) stepperOptions?: StepperOptions) {
@Optional() @Inject(STEPPER_GLOBAL_OPTIONS) stepperOptions?: StepperOptions,
private _viewContainerRef?: ViewContainerRef) {
super(stepper, stepperOptions);
}

ngAfterContentInit() {
/** @breaking-change 9.0.0 Null check for _viewContainerRef to be removed. */
if (this._viewContainerRef) {
this._isSelected = this._stepper.steps.changes.pipe(switchMap(() => {
return this._stepper.selectionChange.pipe(
map(event => event.selectedStep === this),
startWith(this._stepper.selected === this)
);
})).subscribe(isSelected => {
if (isSelected && this._lazyContent && !this._portal) {
this._portal = new TemplatePortal(this._lazyContent._template, this._viewContainerRef!);
}
});
}
}

ngOnDestroy() {
this._isSelected.unsubscribe();
}

/** Custom error state matcher that additionally checks for validity of interacted form. */
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean {
const originalErrorState = this._errorStateMatcher.isErrorState(control, form);
Expand Down
21 changes: 16 additions & 5 deletions tools/public_api_guard/material/stepper.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,24 @@ export declare class MatHorizontalStepper extends MatStepper {
static ɵfac: i0.ɵɵFactoryDef<MatHorizontalStepper, never>;
}

export declare class MatStep extends CdkStep implements ErrorStateMatcher {
export declare class MatStep extends CdkStep implements ErrorStateMatcher, AfterContentInit, OnDestroy {
_lazyContent: MatStepContent;
_portal: TemplatePortal;
color: ThemePalette;
stepLabel: MatStepLabel;
constructor(stepper: MatStepper, _errorStateMatcher: ErrorStateMatcher, stepperOptions?: StepperOptions);
constructor(stepper: MatStepper, _errorStateMatcher: ErrorStateMatcher, stepperOptions?: StepperOptions, _viewContainerRef?: ViewContainerRef | undefined);
isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean;
static ɵcmp: i0.ɵɵComponentDefWithMeta<MatStep, "mat-step", ["matStep"], { "color": "color"; }, {}, ["stepLabel"], ["*"]>;
static ɵfac: i0.ɵɵFactoryDef<MatStep, [null, { skipSelf: true; }, { optional: true; }]>;
ngAfterContentInit(): void;
ngOnDestroy(): void;
static ɵcmp: i0.ɵɵComponentDefWithMeta<MatStep, "mat-step", ["matStep"], { "color": "color"; }, {}, ["stepLabel", "_lazyContent"], ["*"]>;
static ɵfac: i0.ɵɵFactoryDef<MatStep, [null, { skipSelf: true; }, { optional: true; }, null]>;
}

export declare class MatStepContent {
_template: TemplateRef<any>;
constructor(_template: TemplateRef<any>);
static ɵdir: i0.ɵɵDirectiveDefWithMeta<MatStepContent, "ng-template[matStepContent]", never, {}, {}, never>;
static ɵfac: i0.ɵɵFactoryDef<MatStepContent, never>;
}

export declare class MatStepHeader extends _MatStepHeaderMixinBase implements AfterViewInit, OnDestroy, CanColor {
Expand Down Expand Up @@ -105,7 +116,7 @@ export declare class MatStepperIntl {

export declare class MatStepperModule {
static ɵinj: i0.ɵɵInjectorDef<MatStepperModule>;
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]>;
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]>;
}

export declare class MatStepperNext extends CdkStepperNext {
Expand Down

0 comments on commit 52f39bc

Please sign in to comment.