Skip to content

Commit 2482274

Browse files
committed
feat(material/tabs): add the ability to keep content inside the DOM while off-screen
Adds the `preserveContent` input which allows consumers to opt into keeping the content of off-screen tabs inside the DOM. This is useful primarily for edge cases like iframes and videos where removing the element from the DOM will cause it to reload. One gotcha here is that we have to set `visibility: hidden` on the off-screen content so that users can't tab into it. Fixes #19480.
1 parent 202c667 commit 2482274

File tree

13 files changed

+199
-11
lines changed

13 files changed

+199
-11
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {TabGroupHarnessExample} from './tab-group-harness/tab-group-harness-exam
1717
import {TabGroupDynamicExample} from './tab-group-dynamic/tab-group-dynamic-example';
1818
import {TabGroupHeaderBelowExample} from './tab-group-header-below/tab-group-header-below-example';
1919
import {TabGroupLazyLoadedExample} from './tab-group-lazy-loaded/tab-group-lazy-loaded-example';
20+
import {TabGroupPreserveContentExample} from './tab-group-preserve-content/tab-group-preserve-content-example';
2021
import {TabGroupStretchedExample} from './tab-group-stretched/tab-group-stretched-example';
2122
import {TabGroupThemeExample} from './tab-group-theme/tab-group-theme-example';
2223
import {TabNavBarBasicExample} from './tab-nav-bar-basic/tab-nav-bar-basic-example';
@@ -35,6 +36,7 @@ export {
3536
TabGroupStretchedExample,
3637
TabGroupThemeExample,
3738
TabNavBarBasicExample,
39+
TabGroupPreserveContentExample,
3840
};
3941

4042
const EXAMPLES = [
@@ -51,6 +53,7 @@ const EXAMPLES = [
5153
TabGroupStretchedExample,
5254
TabGroupThemeExample,
5355
TabNavBarBasicExample,
56+
TabGroupPreserveContentExample,
5457
];
5558

5659
@NgModule({
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<p>Start the video in the first tab and navigate to the second one to see how it keeps playing.</p>
2+
3+
<mat-tab-group [preserveContent]="true">
4+
<mat-tab label="First">
5+
<iframe
6+
width="560"
7+
height="315"
8+
src="https://www.youtube.com/embed/B-lipaiZII8"
9+
frameborder="0"
10+
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
11+
allowfullscreen></iframe>
12+
</mat-tab>
13+
<mat-tab label="Second">Note how the video from the previous tab is still playing.</mat-tab>
14+
</mat-tab-group>
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import {Component} from '@angular/core';
2+
3+
/**
4+
* @title Tab group that keeps its content inside the DOM when it's off-screen.
5+
*/
6+
@Component({
7+
selector: 'tab-group-preserve-content-example',
8+
templateUrl: 'tab-group-preserve-content-example.html',
9+
})
10+
export class TabGroupPreserveContentExample {}

src/material-experimental/mdc-tabs/tab-group.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@
6161
[position]="tab.position!"
6262
[origin]="tab.origin"
6363
[animationDuration]="animationDuration"
64+
[preserveContent]="preserveContent"
6465
(_onCentered)="_removeTabBodyWrapperHeight()"
6566
(_onCentering)="_setTabBodyWrapperHeight($event)">
6667
</mat-tab-body>

src/material-experimental/mdc-tabs/tab-group.spec.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -660,6 +660,56 @@ describe('MDC-based MatTabGroup', () => {
660660

661661
expect(tabGroupNode.classList).toContain('mat-mdc-tab-group-inverted-header');
662662
});
663+
664+
it('should be able to opt into keeping the inactive tab content in the DOM', fakeAsync(() => {
665+
fixture.componentInstance.preserveContent = true;
666+
fixture.detectChanges();
667+
668+
expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
669+
expect(fixture.nativeElement.textContent).not.toContain('Peanuts');
670+
671+
tabGroup.selectedIndex = 3;
672+
fixture.detectChanges();
673+
tick();
674+
675+
expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
676+
expect(fixture.nativeElement.textContent).toContain('Peanuts');
677+
}));
678+
679+
it('should visibly hide the content of inactive tabs', fakeAsync(() => {
680+
const contentElements: HTMLElement[] = Array.from(
681+
fixture.nativeElement.querySelectorAll('.mat-mdc-tab-body-content'),
682+
);
683+
684+
expect(contentElements.map(element => element.style.visibility)).toEqual([
685+
'visible',
686+
'hidden',
687+
'hidden',
688+
'hidden',
689+
]);
690+
691+
tabGroup.selectedIndex = 2;
692+
fixture.detectChanges();
693+
tick();
694+
695+
expect(contentElements.map(element => element.style.visibility)).toEqual([
696+
'hidden',
697+
'hidden',
698+
'visible',
699+
'hidden',
700+
]);
701+
702+
tabGroup.selectedIndex = 1;
703+
fixture.detectChanges();
704+
tick();
705+
706+
expect(contentElements.map(element => element.style.visibility)).toEqual([
707+
'hidden',
708+
'visible',
709+
'hidden',
710+
'hidden',
711+
]);
712+
}));
663713
});
664714

665715
describe('lazy loaded tabs', () => {
@@ -1065,7 +1115,7 @@ class AsyncTabsTestApp implements OnInit {
10651115

10661116
@Component({
10671117
template: `
1068-
<mat-tab-group>
1118+
<mat-tab-group [preserveContent]="preserveContent">
10691119
<mat-tab label="Junk food"> Pizza, fries </mat-tab>
10701120
<mat-tab label="Vegetables"> Broccoli, spinach </mat-tab>
10711121
<mat-tab [label]="otherLabel"> {{otherContent}} </mat-tab>
@@ -1074,6 +1124,7 @@ class AsyncTabsTestApp implements OnInit {
10741124
`,
10751125
})
10761126
class TabGroupWithSimpleApi {
1127+
preserveContent = false;
10771128
otherLabel = 'Fruit';
10781129
otherContent = 'Apples, grapes';
10791130
@ViewChild('legumes') legumes: any;

src/material/tabs/tab-body.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,9 @@ export class MatTabBodyPortal extends CdkPortalOutlet implements OnInit, OnDestr
9393
});
9494

9595
this._leavingSub = this._host._afterLeavingCenter.subscribe(() => {
96-
this.detach();
96+
if (!this._host.preserveContent) {
97+
this.detach();
98+
}
9799
});
98100
}
99101

@@ -149,6 +151,9 @@ export abstract class _MatTabBodyBase implements OnInit, OnDestroy {
149151
/** Duration for the tab's animation. */
150152
@Input() animationDuration: string = '500ms';
151153

154+
/** Whether the tab's content should be kept in the DOM while it's off-screen. */
155+
@Input() preserveContent: boolean = false;
156+
152157
/** The shifted index position of the tab body, where zero represents the active center tab. */
153158
@Input()
154159
set position(position: number) {

src/material/tabs/tab-config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,13 @@ export interface MatTabsConfig {
2929

3030
/** `tabindex` to be set on the inner element that wraps the tab content. */
3131
contentTabIndex?: number;
32+
33+
/**
34+
* By default tabs remove their content from the DOM while it's off-screen.
35+
* Setting this to `true` will keep it in the DOM which will prevent elements
36+
* like iframes and videos from reloading next time it comes back into the view.
37+
*/
38+
preserveContent?: boolean;
3239
}
3340

3441
/** Injection token that can be used to provide the default options the tabs module. */

src/material/tabs/tab-group.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
[position]="tab.position!"
4848
[origin]="tab.origin"
4949
[animationDuration]="animationDuration"
50+
[preserveContent]="preserveContent"
5051
(_onCentered)="_removeTabBodyWrapperHeight()"
5152
(_onCentering)="_setTabBodyWrapperHeight($event)">
5253
</mat-tab-body>

src/material/tabs/tab-group.spec.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -659,6 +659,56 @@ describe('MatTabGroup', () => {
659659

660660
expect(tabGroupNode.classList).toContain('mat-tab-group-inverted-header');
661661
});
662+
663+
it('should be able to opt into keeping the inactive tab content in the DOM', fakeAsync(() => {
664+
fixture.componentInstance.preserveContent = true;
665+
fixture.detectChanges();
666+
667+
expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
668+
expect(fixture.nativeElement.textContent).not.toContain('Peanuts');
669+
670+
tabGroup.selectedIndex = 3;
671+
fixture.detectChanges();
672+
tick();
673+
674+
expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
675+
expect(fixture.nativeElement.textContent).toContain('Peanuts');
676+
}));
677+
678+
it('should visibly hide the content of inactive tabs', fakeAsync(() => {
679+
const contentElements: HTMLElement[] = Array.from(
680+
fixture.nativeElement.querySelectorAll('.mat-tab-body-content'),
681+
);
682+
683+
expect(contentElements.map(element => element.style.visibility)).toEqual([
684+
'visible',
685+
'hidden',
686+
'hidden',
687+
'hidden',
688+
]);
689+
690+
tabGroup.selectedIndex = 2;
691+
fixture.detectChanges();
692+
tick();
693+
694+
expect(contentElements.map(element => element.style.visibility)).toEqual([
695+
'hidden',
696+
'hidden',
697+
'visible',
698+
'hidden',
699+
]);
700+
701+
tabGroup.selectedIndex = 1;
702+
fixture.detectChanges();
703+
tick();
704+
705+
expect(contentElements.map(element => element.style.visibility)).toEqual([
706+
'hidden',
707+
'visible',
708+
'hidden',
709+
'hidden',
710+
]);
711+
}));
662712
});
663713

664714
describe('lazy loaded tabs', () => {
@@ -1011,7 +1061,7 @@ class AsyncTabsTestApp implements OnInit {
10111061

10121062
@Component({
10131063
template: `
1014-
<mat-tab-group>
1064+
<mat-tab-group [preserveContent]="preserveContent">
10151065
<mat-tab label="Junk food"> Pizza, fries </mat-tab>
10161066
<mat-tab label="Vegetables"> Broccoli, spinach </mat-tab>
10171067
<mat-tab [label]="otherLabel"> {{otherContent}} </mat-tab>
@@ -1020,6 +1070,7 @@ class AsyncTabsTestApp implements OnInit {
10201070
`,
10211071
})
10221072
class TabGroupWithSimpleApi {
1073+
preserveContent = false;
10231074
otherLabel = 'Fruit';
10241075
otherContent = 'Apples, grapes';
10251076
@ViewChild('legumes') legumes: any;

src/material/tabs/tab-group.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,14 @@ export abstract class _MatTabGroupBase
162162
@Input()
163163
disablePagination: boolean;
164164

165+
/**
166+
* By default tabs remove their content from the DOM while it's off-screen.
167+
* Setting this to `true` will keep it in the DOM which will prevent elements
168+
* like iframes and videos from reloading next time it comes back into the view.
169+
*/
170+
@Input()
171+
preserveContent: boolean;
172+
165173
/** Background color of the tab group. */
166174
@Input()
167175
get backgroundColor(): ThemePalette {
@@ -213,6 +221,7 @@ export abstract class _MatTabGroupBase
213221
this.dynamicHeight =
214222
defaultConfig && defaultConfig.dynamicHeight != null ? defaultConfig.dynamicHeight : false;
215223
this.contentTabIndex = defaultConfig?.contentTabIndex ?? null;
224+
this.preserveContent = !!defaultConfig?.preserveContent;
216225
}
217226

218227
/**

src/material/tabs/tabs-animations.ts

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,26 +23,50 @@ export const matTabsAnimations: {
2323
} = {
2424
/** Animation translates a tab along the X axis. */
2525
translateTab: trigger('translateTab', [
26-
// Note: transitions to `none` instead of 0, because some browsers might blur the content.
27-
state('center, void, left-origin-center, right-origin-center', style({transform: 'none'})),
26+
state(
27+
'center, void, left-origin-center, right-origin-center',
28+
style({
29+
// Transitions to `none` instead of 0, because some browsers might blur the content.
30+
transform: 'none',
31+
// Ensures that the `visibility: hidden` from below is cleared.
32+
visibility: 'visible',
33+
}),
34+
),
2835

2936
// If the tab is either on the left or right, we additionally add a `min-height` of 1px
3037
// in order to ensure that the element has a height before its state changes. This is
3138
// necessary because Chrome does seem to skip the transition in RTL mode if the element does
3239
// not have a static height and is not rendered. See related issue: #9465
33-
state('left', style({transform: 'translate3d(-100%, 0, 0)', minHeight: '1px'})),
34-
state('right', style({transform: 'translate3d(100%, 0, 0)', minHeight: '1px'})),
40+
state(
41+
'left',
42+
style({
43+
transform: 'translate3d(-100%, 0, 0)',
44+
minHeight: '1px',
45+
46+
// Normally this is redundant since we detach the content from the DOM, but if the user
47+
// opted into keeping the content in the DOM, we have to hide it so it isn't focusable.
48+
visibility: 'hidden',
49+
}),
50+
),
51+
state(
52+
'right',
53+
style({
54+
transform: 'translate3d(100%, 0, 0)',
55+
minHeight: '1px',
56+
visibility: 'hidden',
57+
}),
58+
),
3559

3660
transition(
3761
'* => left, * => right, left => center, right => center',
3862
animate('{{animationDuration}} cubic-bezier(0.35, 0, 0.25, 1)'),
3963
),
4064
transition('void => left-origin-center', [
41-
style({transform: 'translate3d(-100%, 0, 0)'}),
65+
style({transform: 'translate3d(-100%, 0, 0)', visibility: 'hidden'}),
4266
animate('{{animationDuration}} cubic-bezier(0.35, 0, 0.25, 1)'),
4367
]),
4468
transition('void => right-origin-center', [
45-
style({transform: 'translate3d(100%, 0, 0)'}),
69+
style({transform: 'translate3d(100%, 0, 0)', visibility: 'hidden'}),
4670
animate('{{animationDuration}} cubic-bezier(0.35, 0, 0.25, 1)'),
4771
]),
4872
]),

src/material/tabs/tabs.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ duration can be configured globally using the `MAT_TABS_CONFIG` injection token.
8484
"file": "tab-group-animations-example.html",
8585
"region": "slow-animation-duration"}) -->
8686

87+
### Keeping the tab content inside the DOM while it's off-screen
88+
By default the `<mat-tab-group>` will remove the content of off-screen tabs from the DOM until they
89+
come into the view. This is optimal for most cases since it keeps the DOM size smaller, but it
90+
isn't great for others like when a tab has an `<audio>` or `<video>` element, because the content
91+
will be re-initialized whenever the user navigates to the tab. If you want to keep the content of
92+
off-screen tabs in the DOM, you can set the `preserveContent` input to `true`.
93+
94+
<!-- example(tab-group-preserve-content) -->
95+
8796
### Accessibility
8897
`MatTabGroup` and `MatTabNavBar` implement different interaction patterns for different use-cases.
8998
You should choose the component that works best for your application.

tools/public_api_guard/material/tabs.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,9 +161,10 @@ export abstract class _MatTabBodyBase implements OnInit, OnDestroy {
161161
abstract _portalHost: CdkPortalOutlet;
162162
set position(position: number);
163163
_position: MatTabBodyPositionState;
164+
preserveContent: boolean;
164165
readonly _translateTabComplete: Subject<AnimationEvent_2>;
165166
// (undocumented)
166-
static ɵdir: i0.ɵɵDirectiveDeclaration<_MatTabBodyBase, never, never, { "_content": "content"; "origin": "origin"; "animationDuration": "animationDuration"; "position": "position"; }, { "_onCentering": "_onCentering"; "_beforeCentering": "_beforeCentering"; "_afterLeavingCenter": "_afterLeavingCenter"; "_onCentered": "_onCentered"; }, never>;
167+
static ɵdir: i0.ɵɵDirectiveDeclaration<_MatTabBodyBase, never, never, { "_content": "content"; "origin": "origin"; "animationDuration": "animationDuration"; "preserveContent": "preserveContent"; "position": "position"; }, { "_onCentering": "_onCentering"; "_beforeCentering": "_beforeCentering"; "_afterLeavingCenter": "_afterLeavingCenter"; "_onCentered": "_onCentered"; }, never>;
167168
// (undocumented)
168169
static ɵfac: i0.ɵɵFactoryDeclaration<_MatTabBodyBase, [null, { optional: true; }, null]>;
169170
}
@@ -259,6 +260,7 @@ export abstract class _MatTabGroupBase extends _MatTabGroupMixinBase implements
259260
ngAfterContentInit(): void;
260261
// (undocumented)
261262
ngOnDestroy(): void;
263+
preserveContent: boolean;
262264
realignInkBar(): void;
263265
_removeTabBodyWrapperHeight(): void;
264266
get selectedIndex(): number | null;
@@ -273,7 +275,7 @@ export abstract class _MatTabGroupBase extends _MatTabGroupMixinBase implements
273275
abstract _tabHeader: MatTabGroupBaseHeader;
274276
_tabs: QueryList<MatTab>;
275277
// (undocumented)
276-
static ɵdir: i0.ɵɵDirectiveDeclaration<_MatTabGroupBase, never, never, { "dynamicHeight": "dynamicHeight"; "selectedIndex": "selectedIndex"; "headerPosition": "headerPosition"; "animationDuration": "animationDuration"; "contentTabIndex": "contentTabIndex"; "disablePagination": "disablePagination"; "backgroundColor": "backgroundColor"; }, { "selectedIndexChange": "selectedIndexChange"; "focusChange": "focusChange"; "animationDone": "animationDone"; "selectedTabChange": "selectedTabChange"; }, never>;
278+
static ɵdir: i0.ɵɵDirectiveDeclaration<_MatTabGroupBase, never, never, { "dynamicHeight": "dynamicHeight"; "selectedIndex": "selectedIndex"; "headerPosition": "headerPosition"; "animationDuration": "animationDuration"; "contentTabIndex": "contentTabIndex"; "disablePagination": "disablePagination"; "preserveContent": "preserveContent"; "backgroundColor": "backgroundColor"; }, { "selectedIndexChange": "selectedIndexChange"; "focusChange": "focusChange"; "animationDone": "animationDone"; "selectedTabChange": "selectedTabChange"; }, never>;
277279
// (undocumented)
278280
static ɵfac: i0.ɵɵFactoryDeclaration<_MatTabGroupBase, [null, null, { optional: true; }, { optional: true; }]>;
279281
}
@@ -445,6 +447,7 @@ export interface MatTabsConfig {
445447
disablePagination?: boolean;
446448
dynamicHeight?: boolean;
447449
fitInkBarToContent?: boolean;
450+
preserveContent?: boolean;
448451
}
449452

450453
// @public (undocumented)

0 commit comments

Comments
 (0)