Skip to content

Commit f6ef924

Browse files
committed
feat(expansion-panel): allow for content to be rendered lazily
Currently the expansion panel renders all of its content on load which can be expensive, considering that all of it won't be visible. These changes allow for some of the content to be rendered lazily via the `matExpansionPanelContent` directive. Fixes #8230.
1 parent 541a95e commit f6ef924

File tree

9 files changed

+138
-12
lines changed

9 files changed

+138
-12
lines changed

src/demo-app/expansion/expansion-demo.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ <h1>Single Expansion Panel</h1>
66
<mat-panel-title>Panel Title</mat-panel-title>
77
</mat-expansion-panel-header>
88

9+
<ng-template matExpansionPanelContent>
910
This is the content text that makes sense here.
1011
<mat-checkbox>Trigger a ripple</mat-checkbox>
12+
</ng-template>
1113

1214
<mat-action-row>
1315
<button mat-button (click)="myPanel.expanded = false">CANCEL</button>

src/lib/expansion/expansion-module.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import {NgModule} from '@angular/core';
1111
import {UNIQUE_SELECTION_DISPATCHER_PROVIDER} from '@angular/cdk/collections';
1212
import {CdkAccordionModule} from '@angular/cdk/accordion';
1313
import {A11yModule} from '@angular/cdk/a11y';
14+
import {PortalModule} from '@angular/cdk/portal';
1415
import {MatAccordion} from './accordion';
16+
import {MatExpansionPanelContent} from './expansion-panel-content';
1517
import {
1618
MatExpansionPanel,
1719
MatExpansionPanelActionRow,
@@ -25,14 +27,15 @@ import {
2527

2628

2729
@NgModule({
28-
imports: [CommonModule, A11yModule, CdkAccordionModule],
30+
imports: [CommonModule, A11yModule, CdkAccordionModule, PortalModule],
2931
exports: [
3032
MatAccordion,
3133
MatExpansionPanel,
3234
MatExpansionPanelActionRow,
3335
MatExpansionPanelHeader,
3436
MatExpansionPanelTitle,
35-
MatExpansionPanelDescription
37+
MatExpansionPanelDescription,
38+
MatExpansionPanelContent,
3639
],
3740
declarations: [
3841
MatExpansionPanelBase,
@@ -41,7 +44,8 @@ import {
4144
MatExpansionPanelActionRow,
4245
MatExpansionPanelHeader,
4346
MatExpansionPanelTitle,
44-
MatExpansionPanelDescription
47+
MatExpansionPanelDescription,
48+
MatExpansionPanelContent,
4549
],
4650
providers: [UNIQUE_SELECTION_DISPATCHER_PROVIDER]
4751
})
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
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+
* Expansion panel content that will be rendered lazily
13+
* after the panel is opened for the first time.
14+
*/
15+
@Directive({
16+
selector: 'ng-template[matExpansionPanelContent]'
17+
})
18+
export class MatExpansionPanelContent {
19+
constructor(public _template: TemplateRef<any>) {}
20+
}
Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
<ng-content select="mat-expansion-panel-header"></ng-content>
2-
<div [class.mat-expanded]="expanded" class="mat-expansion-panel-content"
3-
[@bodyExpansion]="_getExpandedState()" [id]="id">
2+
<div class="mat-expansion-panel-content"
3+
[class.mat-expanded]="expanded"
4+
[@bodyExpansion]="_getExpandedState()"
5+
[id]="id">
46
<div class="mat-expansion-panel-body">
57
<ng-content></ng-content>
8+
<ng-template [cdkPortalOutlet]="_portal"></ng-template>
69
</div>
710
<ng-content select="mat-action-row"></ng-content>
811
</div>

src/lib/expansion/expansion-panel.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,21 @@ import {
2020
Optional,
2121
SimpleChanges,
2222
ViewEncapsulation,
23+
ViewContainerRef,
24+
AfterContentInit,
25+
ContentChild,
2326
} from '@angular/core';
2427
import {CdkAccordionItem} from '@angular/cdk/accordion';
2528
import {UniqueSelectionDispatcher} from '@angular/cdk/collections';
2629
import {CanDisable, mixinDisabled} from '@angular/material/core';
30+
import {TemplatePortal} from '@angular/cdk/portal';
2731
import {Subject} from 'rxjs/Subject';
32+
import {take} from 'rxjs/operators/take';
33+
import {filter} from 'rxjs/operators/filter';
34+
import {startWith} from 'rxjs/operators/startWith';
2835
import {MatAccordion} from './accordion';
2936
import {coerceBooleanProperty} from '@angular/cdk/coercion';
37+
import {MatExpansionPanelContent} from './expansion-panel-content';
3038

3139
/** Workaround for https://github.com/angular/angular/issues/17849 */
3240
export const _CdkAccordionItem = CdkAccordionItem;
@@ -91,7 +99,7 @@ export type MatExpansionPanelState = 'expanded' | 'collapsed';
9199
],
92100
})
93101
export class MatExpansionPanel extends _MatExpansionPanelMixinBase
94-
implements CanDisable, OnChanges, OnDestroy {
102+
implements CanDisable, AfterContentInit, OnChanges, OnDestroy {
95103

96104
/** Whether the toggle indicator should be hidden. */
97105
@Input()
@@ -109,9 +117,16 @@ export class MatExpansionPanel extends _MatExpansionPanelMixinBase
109117
/** Optionally defined accordion the expansion panel belongs to. */
110118
accordion: MatAccordion;
111119

120+
/** Content that will be rendered lazily. */
121+
@ContentChild(MatExpansionPanelContent) _lazyContent: MatExpansionPanelContent;
122+
123+
/** Portal holding the user's content. */
124+
_portal: TemplatePortal<any>;
125+
112126
constructor(@Optional() @Host() accordion: MatAccordion,
113127
_changeDetectorRef: ChangeDetectorRef,
114-
_uniqueSelectionDispatcher: UniqueSelectionDispatcher) {
128+
_uniqueSelectionDispatcher: UniqueSelectionDispatcher,
129+
private _viewContainerRef: ViewContainerRef) {
115130
super(accordion, _changeDetectorRef, _uniqueSelectionDispatcher);
116131
this.accordion = accordion;
117132
}
@@ -137,6 +152,19 @@ export class MatExpansionPanel extends _MatExpansionPanelMixinBase
137152
return this.expanded ? 'expanded' : 'collapsed';
138153
}
139154

155+
ngAfterContentInit() {
156+
if (this._lazyContent) {
157+
// Render the content as soon as the panel becomes open.
158+
this.opened.pipe(
159+
startWith(null!),
160+
filter(() => this.expanded && !this._portal),
161+
take(1)
162+
).subscribe(() => {
163+
this._portal = new TemplatePortal<any>(this._lazyContent._template, this._viewContainerRef);
164+
});
165+
}
166+
}
167+
140168
ngOnChanges(changes: SimpleChanges) {
141169
this._inputChanges.next(changes);
142170
}

src/lib/expansion/expansion.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,25 @@ panel can be expanded at a given time:
9999
</mat-accordion>
100100
```
101101

102+
### Lazy rendering
103+
By default, the expansion panel content will be initialized even when the panel is closed.
104+
To instead defer initialization until the panel is open, the content should be provided as
105+
an `ng-template`:
106+
```html
107+
<mat-expansion-panel>
108+
<mat-expansion-panel-header>
109+
This is the expansion title
110+
</mat-expansion-panel-header>
111+
112+
<ng-template matExpansionPanelContent>
113+
Some deferred content
114+
</ng-template>
115+
</mat-expansion-panel>
116+
```
117+
102118
### Accessibility
103119
The expansion-panel aims to mimic the experience of the native `<details>` and `<summary>` elements.
104-
The expansion panel header has `role="button"` and also the attribute `aria-controls` with the
120+
The expansion panel header has `role="button"` and also the attribute `aria-controls` with the
105121
expansion panel's id as value.
106122

107123
The expansion panel headers are buttons. Users can use the keyboard to activate the expansion panel

src/lib/expansion/expansion.spec.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ describe('MatExpansionPanel', () => {
1414
],
1515
declarations: [
1616
PanelWithContent,
17-
PanelWithCustomMargin
17+
PanelWithCustomMargin,
18+
LazyPanelWithContent,
19+
LazyPanelOpenOnLoad,
1820
],
1921
});
2022
TestBed.compileComponents();
@@ -34,6 +36,29 @@ describe('MatExpansionPanel', () => {
3436
expect(contentEl.classes['mat-expanded']).toBeTruthy();
3537
});
3638

39+
it('should be able to render panel content lazily', fakeAsync(() => {
40+
let fixture = TestBed.createComponent(LazyPanelWithContent);
41+
let content = fixture.debugElement.query(By.css('.mat-expansion-panel-content')).nativeElement;
42+
fixture.detectChanges();
43+
44+
expect(content.textContent.trim()).toBe('', 'Expected content element to be empty.');
45+
46+
fixture.componentInstance.expanded = true;
47+
fixture.detectChanges();
48+
49+
expect(content.textContent.trim())
50+
.toContain('Some content', 'Expected content to be rendered.');
51+
}));
52+
53+
it('should render the content for a lazy-loaded panel that is opened on init', fakeAsync(() => {
54+
let fixture = TestBed.createComponent(LazyPanelOpenOnLoad);
55+
let content = fixture.debugElement.query(By.css('.mat-expansion-panel-content')).nativeElement;
56+
fixture.detectChanges();
57+
58+
expect(content.textContent.trim())
59+
.toContain('Some content', 'Expected content to be rendered.');
60+
}));
61+
3762
it('emit correct events for change in panel expanded state', () => {
3863
const fixture = TestBed.createComponent(PanelWithContent);
3964
fixture.componentInstance.expanded = true;
@@ -60,12 +85,13 @@ describe('MatExpansionPanel', () => {
6085

6186
it('should not be able to focus content while closed', fakeAsync(() => {
6287
const fixture = TestBed.createComponent(PanelWithContent);
63-
const button = fixture.debugElement.query(By.css('button')).nativeElement;
6488

6589
fixture.componentInstance.expanded = true;
6690
fixture.detectChanges();
6791
tick(250);
6892

93+
const button = fixture.debugElement.query(By.css('button')).nativeElement;
94+
6995
button.focus();
7096
expect(document.activeElement).toBe(button, 'Expected button to start off focusable.');
7197

@@ -237,3 +263,30 @@ class PanelWithContent {
237263
class PanelWithCustomMargin {
238264
expanded: boolean = false;
239265
}
266+
267+
@Component({
268+
template: `
269+
<mat-expansion-panel [expanded]="expanded">
270+
<mat-expansion-panel-header>Panel Title</mat-expansion-panel-header>
271+
272+
<ng-template matExpansionPanelContent>
273+
<p>Some content</p>
274+
<button>I am a button</button>
275+
</ng-template>
276+
</mat-expansion-panel>`
277+
})
278+
class LazyPanelWithContent {
279+
expanded = false;
280+
}
281+
282+
@Component({
283+
template: `
284+
<mat-expansion-panel [expanded]="true">
285+
<mat-expansion-panel-header>Panel Title</mat-expansion-panel-header>
286+
287+
<ng-template matExpansionPanelContent>
288+
<p>Some content</p>
289+
</ng-template>
290+
</mat-expansion-panel>`
291+
})
292+
class LazyPanelOpenOnLoad {}

src/lib/expansion/public-api.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,4 @@ export * from './expansion-module';
1010
export * from './accordion';
1111
export * from './expansion-panel';
1212
export * from './expansion-panel-header';
13-
14-
13+
export * from './expansion-panel-content';

tools/package-tools/rollup-globals.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ export const rollupGlobals = {
8484
'rxjs/operators/share': 'Rx.Observable',
8585
'rxjs/operators/delay': 'Rx.Observable',
8686
'rxjs/operators/combineLatest': 'Rx.Observable',
87+
'rxjs/operators/take': 'Rx.Observable',
8788

8889
'rxjs/add/observable/merge': 'Rx.Observable',
8990
'rxjs/add/observable/fromEvent': 'Rx.Observable',

0 commit comments

Comments
 (0)