Skip to content

Commit 4bfb174

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 7cc42f5 commit 4bfb174

File tree

13 files changed

+168
-11
lines changed

13 files changed

+168
-11
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import {TabGroupHarnessExample} from './tab-group-harness/tab-group-harness-exam
1919
import {TabGroupDynamicExample} from './tab-group-dynamic/tab-group-dynamic-example';
2020
import {TabGroupHeaderBelowExample} from './tab-group-header-below/tab-group-header-below-example';
2121
import {TabGroupLazyLoadedExample} from './tab-group-lazy-loaded/tab-group-lazy-loaded-example';
22+
import {
23+
TabGroupPreserveContentExample
24+
} from './tab-group-preserve-content/tab-group-preserve-content-example';
2225
import {TabGroupStretchedExample} from './tab-group-stretched/tab-group-stretched-example';
2326
import {TabGroupThemeExample} from './tab-group-theme/tab-group-theme-example';
2427
import {TabNavBarBasicExample} from './tab-nav-bar-basic/tab-nav-bar-basic-example';
@@ -37,6 +40,7 @@ export {
3740
TabGroupStretchedExample,
3841
TabGroupThemeExample,
3942
TabNavBarBasicExample,
43+
TabGroupPreserveContentExample,
4044
};
4145

4246
const EXAMPLES = [
@@ -53,6 +57,7 @@ const EXAMPLES = [
5357
TabGroupStretchedExample,
5458
TabGroupThemeExample,
5559
TabNavBarBasicExample,
60+
TabGroupPreserveContentExample,
5661
];
5762

5863
@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: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,44 @@ describe('MDC-based MatTabGroup', () => {
641641

642642
expect(tabGroupNode.classList).toContain('mat-mdc-tab-group-inverted-header');
643643
});
644+
645+
it('should be able to opt into keeping the inactive tab content in the DOM', fakeAsync(() => {
646+
fixture.componentInstance.preserveContent = true;
647+
fixture.detectChanges();
648+
649+
expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
650+
expect(fixture.nativeElement.textContent).not.toContain('Peanuts');
651+
652+
tabGroup.selectedIndex = 3;
653+
fixture.detectChanges();
654+
tick();
655+
656+
expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
657+
expect(fixture.nativeElement.textContent).toContain('Peanuts');
658+
}));
659+
660+
it('should visibly hide the content of inactive tabs', fakeAsync(() => {
661+
const contentElements: HTMLElement[] =
662+
Array.from(fixture.nativeElement.querySelectorAll('.mat-mdc-tab-body-content'));
663+
664+
expect(contentElements.map(element => element.style.visibility))
665+
.toEqual(['visible', 'hidden', 'hidden', 'hidden']);
666+
667+
tabGroup.selectedIndex = 2;
668+
fixture.detectChanges();
669+
tick();
670+
671+
expect(contentElements.map(element => element.style.visibility))
672+
.toEqual(['hidden', 'hidden', 'visible', 'hidden']);
673+
674+
tabGroup.selectedIndex = 1;
675+
fixture.detectChanges();
676+
tick();
677+
678+
expect(contentElements.map(element => element.style.visibility))
679+
.toEqual(['hidden', 'visible', 'hidden', 'hidden']);
680+
}));
681+
644682
});
645683

646684
describe('lazy loaded tabs', () => {
@@ -989,7 +1027,7 @@ class AsyncTabsTestApp implements OnInit {
9891027

9901028
@Component({
9911029
template: `
992-
<mat-tab-group>
1030+
<mat-tab-group [preserveContent]="preserveContent">
9931031
<mat-tab label="Junk food"> Pizza, fries </mat-tab>
9941032
<mat-tab label="Vegetables"> Broccoli, spinach </mat-tab>
9951033
<mat-tab [label]="otherLabel"> {{otherContent}} </mat-tab>
@@ -998,6 +1036,7 @@ class AsyncTabsTestApp implements OnInit {
9981036
`
9991037
})
10001038
class TabGroupWithSimpleApi {
1039+
preserveContent = false;
10011040
otherLabel = 'Fruit';
10021041
otherContent = 'Apples, grapes';
10031042
@ViewChild('legumes') legumes: any;

src/material/tabs/tab-body.ts

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

9090
this._leavingSub = this._host._afterLeavingCenter.subscribe(() => {
91-
this.detach();
91+
if (!this._host.preserveContent) {
92+
this.detach();
93+
}
9294
});
9395
}
9496

@@ -144,6 +146,9 @@ export abstract class _MatTabBodyBase implements OnInit, OnDestroy {
144146
/** Duration for the tab's animation. */
145147
@Input() animationDuration: string = '500ms';
146148

149+
/** Whether the tab's content should be kept in the DOM while it's off-screen. */
150+
@Input() preserveContent: boolean = false;
151+
147152
/** The shifted index position of the tab body, where zero represents the active center tab. */
148153
@Input()
149154
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: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -640,6 +640,44 @@ describe('MatTabGroup', () => {
640640

641641
expect(tabGroupNode.classList).toContain('mat-tab-group-inverted-header');
642642
});
643+
644+
it('should be able to opt into keeping the inactive tab content in the DOM', fakeAsync(() => {
645+
fixture.componentInstance.preserveContent = true;
646+
fixture.detectChanges();
647+
648+
expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
649+
expect(fixture.nativeElement.textContent).not.toContain('Peanuts');
650+
651+
tabGroup.selectedIndex = 3;
652+
fixture.detectChanges();
653+
tick();
654+
655+
expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
656+
expect(fixture.nativeElement.textContent).toContain('Peanuts');
657+
}));
658+
659+
it('should visibly hide the content of inactive tabs', fakeAsync(() => {
660+
const contentElements: HTMLElement[] =
661+
Array.from(fixture.nativeElement.querySelectorAll('.mat-tab-body-content'));
662+
663+
expect(contentElements.map(element => element.style.visibility))
664+
.toEqual(['visible', 'hidden', 'hidden', 'hidden']);
665+
666+
tabGroup.selectedIndex = 2;
667+
fixture.detectChanges();
668+
tick();
669+
670+
expect(contentElements.map(element => element.style.visibility))
671+
.toEqual(['hidden', 'hidden', 'visible', 'hidden']);
672+
673+
tabGroup.selectedIndex = 1;
674+
fixture.detectChanges();
675+
tick();
676+
677+
expect(contentElements.map(element => element.style.visibility))
678+
.toEqual(['hidden', 'visible', 'hidden', 'hidden']);
679+
}));
680+
643681
});
644682

645683
describe('lazy loaded tabs', () => {
@@ -933,7 +971,7 @@ class AsyncTabsTestApp implements OnInit {
933971

934972
@Component({
935973
template: `
936-
<mat-tab-group>
974+
<mat-tab-group [preserveContent]="preserveContent">
937975
<mat-tab label="Junk food"> Pizza, fries </mat-tab>
938976
<mat-tab label="Vegetables"> Broccoli, spinach </mat-tab>
939977
<mat-tab [label]="otherLabel"> {{otherContent}} </mat-tab>
@@ -942,6 +980,7 @@ class AsyncTabsTestApp implements OnInit {
942980
`
943981
})
944982
class TabGroupWithSimpleApi {
983+
preserveContent = false;
945984
otherLabel = 'Fruit';
946985
otherContent = 'Apples, grapes';
947986
@ViewChild('legumes') legumes: any;

src/material/tabs/tab-group.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,14 @@ export abstract class _MatTabGroupBase extends _MatTabGroupMixinBase implements
151151
@Input()
152152
disablePagination: boolean;
153153

154+
/**
155+
* By default tabs remove their content from the DOM while it's off-screen.
156+
* Setting this to `true` will keep it in the DOM which will prevent elements
157+
* like iframes and videos from reloading next time it comes back into the view.
158+
*/
159+
@Input()
160+
preserveContent: boolean;
161+
154162
/** Background color of the tab group. */
155163
@Input()
156164
get backgroundColor(): ThemePalette { return this._backgroundColor; }
@@ -196,6 +204,7 @@ export abstract class _MatTabGroupBase extends _MatTabGroupMixinBase implements
196204
this.dynamicHeight = defaultConfig && defaultConfig.dynamicHeight != null ?
197205
defaultConfig.dynamicHeight : false;
198206
this.contentTabIndex = defaultConfig?.contentTabIndex ?? null;
207+
this.preserveContent = !!defaultConfig?.preserveContent;
199208
}
200209

201210
/**

src/material/tabs/tabs-animations.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,39 @@ 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('center, void, left-origin-center, right-origin-center', style({
27+
// Transitions to `none` instead of 0, because some browsers might blur the content.
28+
transform: 'none',
29+
// Ensures that the `visibility: hidden` from below is cleared.
30+
visibility: 'visible'
31+
})),
2832

2933
// If the tab is either on the left or right, we additionally add a `min-height` of 1px
3034
// in order to ensure that the element has a height before its state changes. This is
3135
// necessary because Chrome does seem to skip the transition in RTL mode if the element does
3236
// 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'})),
37+
state('left', style({
38+
transform: 'translate3d(-100%, 0, 0)',
39+
minHeight: '1px',
40+
41+
// Normally this is redundant since we detach the content from the DOM, but if the user
42+
// opted into keeping the content in the DOM, we have to hide it so it isn't focusable.
43+
visibility: 'hidden'
44+
})),
45+
state('right', style({
46+
transform: 'translate3d(100%, 0, 0)',
47+
minHeight: '1px',
48+
visibility: 'hidden'
49+
})),
3550

3651
transition('* => left, * => right, left => center, right => center',
3752
animate('{{animationDuration}} cubic-bezier(0.35, 0, 0.25, 1)')),
3853
transition('void => left-origin-center', [
39-
style({transform: 'translate3d(-100%, 0, 0)'}),
54+
style({transform: 'translate3d(-100%, 0, 0)', visibility: 'hidden'}),
4055
animate('{{animationDuration}} cubic-bezier(0.35, 0, 0.25, 1)')
4156
]),
4257
transition('void => right-origin-center', [
43-
style({transform: 'translate3d(100%, 0, 0)'}),
58+
style({transform: 'translate3d(100%, 0, 0)', visibility: 'hidden'}),
4459
animate('{{animationDuration}} cubic-bezier(0.35, 0, 0.25, 1)')
4560
])
4661
])

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
`<mat-tab-group>` and `<mat-nav-tab-bar>` use different interaction patterns. The
8998
`<mat-tab-group>` component combines `tablist`, `tab`, and `tabpanel` into a single component with

tools/public_api_guard/material/tabs.d.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ export declare abstract class _MatTabBodyBase implements OnInit, OnDestroy {
1919
animationDuration: string;
2020
origin: number | null;
2121
set position(position: number);
22+
preserveContent: boolean;
2223
constructor(_elementRef: ElementRef<HTMLElement>, _dir: Directionality, changeDetectorRef: ChangeDetectorRef);
2324
_getLayoutDirection(): Direction;
2425
_isCenterPosition(position: MatTabBodyPositionState | string): boolean;
2526
_onTranslateTabStarted(event: AnimationEvent): void;
2627
ngOnDestroy(): void;
2728
ngOnInit(): void;
28-
static ɵdir: i0.ɵɵDirectiveDeclaration<_MatTabBodyBase, never, never, { "_content": "content"; "origin": "origin"; "animationDuration": "animationDuration"; "position": "position"; }, { "_onCentering": "_onCentering"; "_beforeCentering": "_beforeCentering"; "_afterLeavingCenter": "_afterLeavingCenter"; "_onCentered": "_onCentered"; }, never>;
29+
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>;
2930
static ɵfac: i0.ɵɵFactoryDeclaration<_MatTabBodyBase, [null, { optional: true; }, null]>;
3031
}
3132

@@ -48,6 +49,7 @@ export declare abstract class _MatTabGroupBase extends _MatTabGroupMixinBase imp
4849
set dynamicHeight(value: boolean);
4950
readonly focusChange: EventEmitter<MatTabChangeEvent>;
5051
headerPosition: MatTabHeaderPosition;
52+
preserveContent: boolean;
5153
get selectedIndex(): number | null;
5254
set selectedIndex(value: number | null);
5355
readonly selectedIndexChange: EventEmitter<number>;
@@ -71,7 +73,7 @@ export declare abstract class _MatTabGroupBase extends _MatTabGroupMixinBase imp
7173
static ngAcceptInputType_disableRipple: BooleanInput;
7274
static ngAcceptInputType_dynamicHeight: BooleanInput;
7375
static ngAcceptInputType_selectedIndex: NumberInput;
74-
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>;
76+
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>;
7577
static ɵfac: i0.ɵɵFactoryDeclaration<_MatTabGroupBase, [null, null, { optional: true; }, { optional: true; }]>;
7678
}
7779

@@ -258,6 +260,7 @@ export interface MatTabsConfig {
258260
disablePagination?: boolean;
259261
dynamicHeight?: boolean;
260262
fitInkBarToContent?: boolean;
263+
preserveContent?: boolean;
261264
}
262265

263266
export declare class MatTabsModule {

0 commit comments

Comments
 (0)