Skip to content

Commit 60ba0a7

Browse files
crisbetoandrewseguin
authored andcommitted
feat(expansion-panel): allow for content to be rendered lazily (#8243)
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 c0ff761 commit 60ba0a7

File tree

8 files changed

+137
-12
lines changed

8 files changed

+137
-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
/** Time and timing curve for expansion panel animations. */
3240
export const EXPANSION_PANEL_ANIMATION_TIMING = '225ms cubic-bezier(0.4,0.0,0.2,1)';
@@ -88,7 +96,7 @@ export type MatExpansionPanelState = 'expanded' | 'collapsed';
8896
],
8997
})
9098
export class MatExpansionPanel extends _MatExpansionPanelMixinBase
91-
implements CanDisable, OnChanges, OnDestroy {
99+
implements CanDisable, AfterContentInit, OnChanges, OnDestroy {
92100

93101
/** Whether the toggle indicator should be hidden. */
94102
@Input()
@@ -106,9 +114,16 @@ export class MatExpansionPanel extends _MatExpansionPanelMixinBase
106114
/** Optionally defined accordion the expansion panel belongs to. */
107115
accordion: MatAccordion;
108116

117+
/** Content that will be rendered lazily. */
118+
@ContentChild(MatExpansionPanelContent) _lazyContent: MatExpansionPanelContent;
119+
120+
/** Portal holding the user's content. */
121+
_portal: TemplatePortal<any>;
122+
109123
constructor(@Optional() @Host() accordion: MatAccordion,
110124
_changeDetectorRef: ChangeDetectorRef,
111-
_uniqueSelectionDispatcher: UniqueSelectionDispatcher) {
125+
_uniqueSelectionDispatcher: UniqueSelectionDispatcher,
126+
private _viewContainerRef: ViewContainerRef) {
112127
super(accordion, _changeDetectorRef, _uniqueSelectionDispatcher);
113128
this.accordion = accordion;
114129
}
@@ -134,6 +149,19 @@ export class MatExpansionPanel extends _MatExpansionPanelMixinBase
134149
return this.expanded ? 'expanded' : 'collapsed';
135150
}
136151

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

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
@@ -15,7 +15,9 @@ describe('MatExpansionPanel', () => {
1515
declarations: [
1616
PanelWithContent,
1717
PanelWithContentInNgIf,
18-
PanelWithCustomMargin
18+
PanelWithCustomMargin,
19+
LazyPanelWithContent,
20+
LazyPanelOpenOnLoad,
1921
],
2022
});
2123
TestBed.compileComponents();
@@ -35,6 +37,29 @@ describe('MatExpansionPanel', () => {
3537
expect(contentEl.classes['mat-expanded']).toBeTruthy();
3638
});
3739

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

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

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

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

@@ -260,3 +286,30 @@ class PanelWithContentInNgIf {
260286
class PanelWithCustomMargin {
261287
expanded: boolean = false;
262288
}
289+
290+
@Component({
291+
template: `
292+
<mat-expansion-panel [expanded]="expanded">
293+
<mat-expansion-panel-header>Panel Title</mat-expansion-panel-header>
294+
295+
<ng-template matExpansionPanelContent>
296+
<p>Some content</p>
297+
<button>I am a button</button>
298+
</ng-template>
299+
</mat-expansion-panel>`
300+
})
301+
class LazyPanelWithContent {
302+
expanded = false;
303+
}
304+
305+
@Component({
306+
template: `
307+
<mat-expansion-panel [expanded]="true">
308+
<mat-expansion-panel-header>Panel Title</mat-expansion-panel-header>
309+
310+
<ng-template matExpansionPanelContent>
311+
<p>Some content</p>
312+
</ng-template>
313+
</mat-expansion-panel>`
314+
})
315+
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';

0 commit comments

Comments
 (0)