From f4023c8700137dd7dc369498ff3ba8c3cc651aa5 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 6 Nov 2020 06:44:09 +0100 Subject: [PATCH] feat(material/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. --- .../material/stepper/index.ts | 3 + .../stepper-lazy-content-example.css | 1 + .../stepper-lazy-content-example.html | 22 +++++++ .../stepper-lazy-content-example.ts | 11 ++++ src/material/stepper/public-api.ts | 1 + src/material/stepper/step-content.ts | 19 ++++++ src/material/stepper/step.html | 5 +- src/material/stepper/stepper-module.ts | 3 + src/material/stepper/stepper.md | 7 +++ src/material/stepper/stepper.spec.ts | 61 +++++++++++++++++++ src/material/stepper/stepper.ts | 42 +++++++++++-- tools/public_api_guard/material/stepper.d.ts | 21 +++++-- 12 files changed, 186 insertions(+), 10 deletions(-) create mode 100644 src/components-examples/material/stepper/stepper-lazy-content/stepper-lazy-content-example.css create mode 100644 src/components-examples/material/stepper/stepper-lazy-content/stepper-lazy-content-example.html create mode 100644 src/components-examples/material/stepper/stepper-lazy-content/stepper-lazy-content-example.ts create mode 100644 src/material/stepper/step-content.ts diff --git a/src/components-examples/material/stepper/index.ts b/src/components-examples/material/stepper/index.ts index 2153b8b54523..a9017f0310ce 100644 --- a/src/components-examples/material/stepper/index.ts +++ b/src/components-examples/material/stepper/index.ts @@ -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, @@ -24,6 +25,7 @@ export { StepperOverviewExample, StepperStatesExample, StepperVerticalExample, + StepperLazyContentExample, }; const EXAMPLES = [ @@ -35,6 +37,7 @@ const EXAMPLES = [ StepperOverviewExample, StepperStatesExample, StepperVerticalExample, + StepperLazyContentExample, ]; @NgModule({ diff --git a/src/components-examples/material/stepper/stepper-lazy-content/stepper-lazy-content-example.css b/src/components-examples/material/stepper/stepper-lazy-content/stepper-lazy-content-example.css new file mode 100644 index 000000000000..7432308753e6 --- /dev/null +++ b/src/components-examples/material/stepper/stepper-lazy-content/stepper-lazy-content-example.css @@ -0,0 +1 @@ +/** No CSS for this example */ diff --git a/src/components-examples/material/stepper/stepper-lazy-content/stepper-lazy-content-example.html b/src/components-examples/material/stepper/stepper-lazy-content/stepper-lazy-content-example.html new file mode 100644 index 000000000000..a4c6c6172863 --- /dev/null +++ b/src/components-examples/material/stepper/stepper-lazy-content/stepper-lazy-content-example.html @@ -0,0 +1,22 @@ + + + Step 1 + +

This content was rendered lazily

+ +
+
+ + Step 2 + +

This content was also rendered lazily

+ + +
+
+ + Step 3 +

This content was rendered eagerly

+ +
+
diff --git a/src/components-examples/material/stepper/stepper-lazy-content/stepper-lazy-content-example.ts b/src/components-examples/material/stepper/stepper-lazy-content/stepper-lazy-content-example.ts new file mode 100644 index 000000000000..533342b75590 --- /dev/null +++ b/src/components-examples/material/stepper/stepper-lazy-content/stepper-lazy-content-example.ts @@ -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 {} diff --git a/src/material/stepper/public-api.ts b/src/material/stepper/public-api.ts index de358424ebf7..fb1e0c2d4b40 100644 --- a/src/material/stepper/public-api.ts +++ b/src/material/stepper/public-api.ts @@ -14,3 +14,4 @@ export * from './step-header'; export * from './stepper-intl'; export * from './stepper-animations'; export * from './stepper-icon'; +export * from './step-content'; diff --git a/src/material/stepper/step-content.ts b/src/material/stepper/step-content.ts new file mode 100644 index 000000000000..19020d46a91f --- /dev/null +++ b/src/material/stepper/step-content.ts @@ -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) {} +} diff --git a/src/material/stepper/step.html b/src/material/stepper/step.html index cd48c06b9917..bd5b263e74ae 100644 --- a/src/material/stepper/step.html +++ b/src/material/stepper/step.html @@ -1 +1,4 @@ - + + + + diff --git a/src/material/stepper/stepper-module.ts b/src/material/stepper/stepper-module.ts index 163023728172..4e97efb2bd40 100644 --- a/src/material/stepper/stepper-module.ts +++ b/src/material/stepper/stepper-module.ts @@ -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({ @@ -42,6 +43,7 @@ import {MAT_STEPPER_INTL_PROVIDER} from './stepper-intl'; MatStepperPrevious, MatStepHeader, MatStepperIcon, + MatStepContent, ], declarations: [ MatHorizontalStepper, @@ -53,6 +55,7 @@ import {MAT_STEPPER_INTL_PROVIDER} from './stepper-intl'; MatStepperPrevious, MatStepHeader, MatStepperIcon, + MatStepContent, ], providers: [MAT_STEPPER_INTL_PROVIDER, ErrorStateMatcher], }) diff --git a/src/material/stepper/stepper.md b/src/material/stepper/stepper.md index 460ef67da1f4..4e0cf06193a4 100644 --- a/src/material/stepper/stepper.md +++ b/src/material/stepper/stepper.md @@ -180,6 +180,13 @@ will not affect steppers marked as `linear`. +### 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. + + + ### Keyboard interaction - LEFT_ARROW: Focuses the previous step header - RIGHT_ARROW: Focuses the next step header diff --git a/src/material/stepper/stepper.spec.ts b/src/material/stepper/stepper.spec.ts index b7657dbb6f3e..b6372529f990 100644 --- a/src/material/stepper/stepper.spec.ts +++ b/src/material/stepper/stepper.spec.ts @@ -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. */ @@ -1826,3 +1864,26 @@ class NestedSteppers { class StepperWithStaticOutOfBoundsIndex { @ViewChild(MatStepper) stepper: MatStepper; } + + +@Component({ + template: ` + + + Step 1 + Step 1 content + + + Step 2 + Step 2 content + + + Step 3 + Step 3 content + + + ` +}) +class StepperWithLazyContent { + selectedIndex = 0; +} diff --git a/src/material/stepper/stepper.ts b/src/material/stepper/stepper.ts index ba64e22ac320..46511f7afcc7 100644 --- a/src/material/stepper/stepper.ts +++ b/src/material/stepper/stepper.ts @@ -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', @@ -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 ``. */ @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); diff --git a/tools/public_api_guard/material/stepper.d.ts b/tools/public_api_guard/material/stepper.d.ts index 2fcca89edde8..b00095c82422 100644 --- a/tools/public_api_guard/material/stepper.d.ts +++ b/tools/public_api_guard/material/stepper.d.ts @@ -16,13 +16,24 @@ export declare class MatHorizontalStepper extends MatStepper { static ɵfac: i0.ɵɵFactoryDef; } -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; - static ɵfac: i0.ɵɵFactoryDef; + ngAfterContentInit(): void; + ngOnDestroy(): void; + static ɵcmp: i0.ɵɵComponentDefWithMeta; + static ɵfac: i0.ɵɵFactoryDef; +} + +export declare class MatStepContent { + _template: TemplateRef; + constructor(_template: TemplateRef); + static ɵdir: i0.ɵɵDirectiveDefWithMeta; + static ɵfac: i0.ɵɵFactoryDef; } export declare class MatStepHeader extends _MatStepHeaderMixinBase implements AfterViewInit, OnDestroy, CanColor { @@ -105,7 +116,7 @@ export declare class MatStepperIntl { export declare class MatStepperModule { static ɵinj: i0.ɵɵInjectorDef; - static ɵmod: i0.ɵɵNgModuleDefWithMeta; + static ɵmod: i0.ɵɵNgModuleDefWithMeta; } export declare class MatStepperNext extends CdkStepperNext {