Skip to content

Commit c3188c8

Browse files
authored
feat(material/tabs): add the ability to keep content inside the DOM while off-screen (#20393)
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 5a00027 commit c3188c8

File tree

15 files changed

+221
-11
lines changed

15 files changed

+221
-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';
@@ -37,6 +38,7 @@ export {
3738
TabGroupThemeExample,
3839
TabNavBarBasicExample,
3940
TabNavBarWithPanelExample,
41+
TabGroupPreserveContentExample,
4042
};
4143

4244
const EXAMPLES = [
@@ -54,6 +56,7 @@ const EXAMPLES = [
5456
TabGroupThemeExample,
5557
TabNavBarBasicExample,
5658
TabNavBarWithPanelExample,
59+
TabGroupPreserveContentExample,
5760
];
5861

5962
@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-body.scss

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@
2222
.mat-mdc-tab-group.mat-mdc-tab-group-dynamic-height &.mat-mdc-tab-body-active {
2323
overflow-y: hidden;
2424
}
25+
26+
// Usually the `visibility: hidden` added by the animation is enough to prevent focus from
27+
// entering the collapsed content, but children with their own `visibility` can override it.
28+
// This is a fallback that completely hides the content when the element becomes hidden.
29+
// Note that we can't do this in the animation definition, because the style gets recomputed too
30+
// late, breaking the animation because Angular didn't have time to figure out the target height.
31+
// This can also be achieved with JS, but it has issues when when starting an animation before
32+
// the previous one has finished.
33+
&[style*='visibility: hidden'] {
34+
display: none;
35+
}
2536
}
2637

2738
.mat-mdc-tab-body-content {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
[position]="tab.position!"
6464
[origin]="tab.origin"
6565
[animationDuration]="animationDuration"
66+
[preserveContent]="preserveContent"
6667
(_onCentered)="_removeTabBodyWrapperHeight()"
6768
(_onCentering)="_setTabBodyWrapperHeight($event)">
6869
</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
@@ -666,6 +666,56 @@ describe('MDC-based MatTabGroup', () => {
666666

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

671721
describe('lazy loaded tabs', () => {
@@ -1126,7 +1176,7 @@ class AsyncTabsTestApp implements OnInit {
11261176

11271177
@Component({
11281178
template: `
1129-
<mat-tab-group>
1179+
<mat-tab-group [preserveContent]="preserveContent">
11301180
<mat-tab label="Junk food"> Pizza, fries </mat-tab>
11311181
<mat-tab label="Vegetables"> Broccoli, spinach </mat-tab>
11321182
<mat-tab [label]="otherLabel"> {{otherContent}} </mat-tab>
@@ -1135,6 +1185,7 @@ class AsyncTabsTestApp implements OnInit {
11351185
`,
11361186
})
11371187
class TabGroupWithSimpleApi {
1188+
preserveContent = false;
11381189
otherLabel = 'Fruit';
11391190
otherContent = 'Apples, grapes';
11401191
@ViewChild('legumes') legumes: any;

src/material/tabs/tab-body.scss

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,15 @@
55
.mat-tab-group-dynamic-height & {
66
overflow: hidden;
77
}
8+
9+
// Usually the `visibility: hidden` added by the animation is enough to prevent focus from
10+
// entering the collapsed content, but children with their own `visibility` can override it.
11+
// This is a fallback that completely hides the content when the element becomes hidden.
12+
// Note that we can't do this in the animation definition, because the style gets recomputed too
13+
// late, breaking the animation because Angular didn't have time to figure out the target height.
14+
// This can also be achieved with JS, but it has issues when when starting an animation before
15+
// the previous one has finished.
16+
&[style*='visibility: hidden'] {
17+
display: none;
18+
}
819
}

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
@@ -50,6 +50,7 @@
5050
[position]="tab.position!"
5151
[origin]="tab.origin"
5252
[animationDuration]="animationDuration"
53+
[preserveContent]="preserveContent"
5354
(_onCentered)="_removeTabBodyWrapperHeight()"
5455
(_onCentering)="_setTabBodyWrapperHeight($event)">
5556
</mat-tab-body>

0 commit comments

Comments
 (0)