Skip to content

Commit af206c2

Browse files
committed
feat(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 f0c7a25 commit af206c2

File tree

9 files changed

+130
-11
lines changed

9 files changed

+130
-11
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
[position]="tab.position!"
6060
[origin]="tab.origin"
6161
[animationDuration]="animationDuration"
62+
[preserveContent]="preserveContent"
6263
(_onCentered)="_removeTabBodyWrapperHeight()"
6364
(_onCentering)="_setTabBodyWrapperHeight($event)">
6465
</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
@@ -578,6 +578,44 @@ describe('MDC-based MatTabGroup', () => {
578578

579579
expect(tabGroupNode.classList).toContain('mat-mdc-tab-group-inverted-header');
580580
});
581+
582+
it('should be able to opt into keeping the inactive tab content in the DOM', fakeAsync(() => {
583+
fixture.componentInstance.preserveContent = true;
584+
fixture.detectChanges();
585+
586+
expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
587+
expect(fixture.nativeElement.textContent).not.toContain('Peanuts');
588+
589+
tabGroup.selectedIndex = 3;
590+
fixture.detectChanges();
591+
tick();
592+
593+
expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
594+
expect(fixture.nativeElement.textContent).toContain('Peanuts');
595+
}));
596+
597+
it('should visibly hide the content of inactive tabs', fakeAsync(() => {
598+
const contentElements: HTMLElement[] =
599+
Array.from(fixture.nativeElement.querySelectorAll('.mat-mdc-tab-body-content'));
600+
601+
expect(contentElements.map(element => element.style.visibility))
602+
.toEqual(['visible', 'hidden', 'hidden', 'hidden']);
603+
604+
tabGroup.selectedIndex = 2;
605+
fixture.detectChanges();
606+
tick();
607+
608+
expect(contentElements.map(element => element.style.visibility))
609+
.toEqual(['hidden', 'hidden', 'visible', 'hidden']);
610+
611+
tabGroup.selectedIndex = 1;
612+
fixture.detectChanges();
613+
tick();
614+
615+
expect(contentElements.map(element => element.style.visibility))
616+
.toEqual(['hidden', 'visible', 'hidden', 'hidden']);
617+
}));
618+
581619
});
582620

583621
describe('lazy loaded tabs', () => {
@@ -919,7 +957,7 @@ class AsyncTabsTestApp implements OnInit {
919957

920958
@Component({
921959
template: `
922-
<mat-tab-group>
960+
<mat-tab-group [preserveContent]="preserveContent">
923961
<mat-tab label="Junk food"> Pizza, fries </mat-tab>
924962
<mat-tab label="Vegetables"> Broccoli, spinach </mat-tab>
925963
<mat-tab [label]="otherLabel"> {{otherContent}} </mat-tab>
@@ -928,6 +966,7 @@ class AsyncTabsTestApp implements OnInit {
928966
`
929967
})
930968
class TabGroupWithSimpleApi {
969+
preserveContent = false;
931970
otherLabel = 'Fruit';
932971
otherContent = 'Apples, grapes';
933972
@ViewChild('legumes') legumes: any;

src/material/tabs/tab-body.ts

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

9494
this._leavingSub = this._host._afterLeavingCenter.subscribe(() => {
95-
this.detach();
95+
if (!this._host.preserveContent) {
96+
this.detach();
97+
}
9698
});
9799
}
98100

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

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

src/material/tabs/tab-config.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ export interface MatTabsConfig {
2323
* This only applies to the MDC-based tabs.
2424
*/
2525
fitInkBarToContent?: boolean;
26+
27+
/**
28+
* By default tabs remove their content from the DOM while it's off-screen.
29+
* Setting this to `true` will keep it in the DOM which will prevent elements
30+
* like iframes and videos from reloading next time it comes back into the view.
31+
*/
32+
preserveContent?: boolean;
2633
}
2734

2835
/** 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
@@ -45,6 +45,7 @@
4545
[position]="tab.position!"
4646
[origin]="tab.origin"
4747
[animationDuration]="animationDuration"
48+
[preserveContent]="preserveContent"
4849
(_onCentered)="_removeTabBodyWrapperHeight()"
4950
(_onCentering)="_setTabBodyWrapperHeight($event)">
5051
</mat-tab-body>

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

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,44 @@ describe('MatTabGroup', () => {
576576

577577
expect(tabGroupNode.classList).toContain('mat-tab-group-inverted-header');
578578
});
579+
580+
it('should be able to opt into keeping the inactive tab content in the DOM', fakeAsync(() => {
581+
fixture.componentInstance.preserveContent = true;
582+
fixture.detectChanges();
583+
584+
expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
585+
expect(fixture.nativeElement.textContent).not.toContain('Peanuts');
586+
587+
tabGroup.selectedIndex = 3;
588+
fixture.detectChanges();
589+
tick();
590+
591+
expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
592+
expect(fixture.nativeElement.textContent).toContain('Peanuts');
593+
}));
594+
595+
it('should visibly hide the content of inactive tabs', fakeAsync(() => {
596+
const contentElements: HTMLElement[] =
597+
Array.from(fixture.nativeElement.querySelectorAll('.mat-tab-body-content'));
598+
599+
expect(contentElements.map(element => element.style.visibility))
600+
.toEqual(['visible', 'hidden', 'hidden', 'hidden']);
601+
602+
tabGroup.selectedIndex = 2;
603+
fixture.detectChanges();
604+
tick();
605+
606+
expect(contentElements.map(element => element.style.visibility))
607+
.toEqual(['hidden', 'hidden', 'visible', 'hidden']);
608+
609+
tabGroup.selectedIndex = 1;
610+
fixture.detectChanges();
611+
tick();
612+
613+
expect(contentElements.map(element => element.style.visibility))
614+
.toEqual(['hidden', 'visible', 'hidden', 'hidden']);
615+
}));
616+
579617
});
580618

581619
describe('lazy loaded tabs', () => {
@@ -842,7 +880,7 @@ class AsyncTabsTestApp implements OnInit {
842880

843881
@Component({
844882
template: `
845-
<mat-tab-group>
883+
<mat-tab-group [preserveContent]="preserveContent">
846884
<mat-tab label="Junk food"> Pizza, fries </mat-tab>
847885
<mat-tab label="Vegetables"> Broccoli, spinach </mat-tab>
848886
<mat-tab [label]="otherLabel"> {{otherContent}} </mat-tab>
@@ -851,6 +889,7 @@ class AsyncTabsTestApp implements OnInit {
851889
`
852890
})
853891
class TabGroupWithSimpleApi {
892+
preserveContent = false;
854893
otherLabel = 'Fruit';
855894
otherContent = 'Apples, grapes';
856895
@ViewChild('legumes') legumes: any;

src/material/tabs/tab-group.ts

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

140+
/**
141+
* By default tabs remove their content from the DOM while it's off-screen.
142+
* Setting this to `true` will keep it in the DOM which will prevent elements
143+
* like iframes and videos from reloading next time it comes back into the view.
144+
*/
145+
@Input()
146+
preserveContent: boolean;
147+
140148
/** Background color of the tab group. */
141149
@Input()
142150
get backgroundColor(): ThemePalette { return this._backgroundColor; }
@@ -179,6 +187,7 @@ export abstract class _MatTabGroupBase extends _MatTabGroupMixinBase implements
179187
defaultConfig.animationDuration : '500ms';
180188
this.disablePagination = defaultConfig && defaultConfig.disablePagination != null ?
181189
defaultConfig.disablePagination : false;
190+
this.preserveContent = !!defaultConfig?.preserveContent;
182191
}
183192

184193
/**

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
])

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.ɵɵDirectiveDefWithMeta<_MatTabBodyBase, never, never, { "_content": "content"; "origin": "origin"; "animationDuration": "animationDuration"; "position": "position"; }, { "_onCentering": "_onCentering"; "_beforeCentering": "_beforeCentering"; "_afterLeavingCenter": "_afterLeavingCenter"; "_onCentered": "_onCentered"; }, never>;
29+
static ɵdir: i0.ɵɵDirectiveDefWithMeta<_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.ɵɵFactoryDef<_MatTabBodyBase, [null, { optional: true; }, null]>;
3031
}
3132

@@ -46,6 +47,7 @@ export declare abstract class _MatTabGroupBase extends _MatTabGroupMixinBase imp
4647
set dynamicHeight(value: boolean);
4748
readonly focusChange: EventEmitter<MatTabChangeEvent>;
4849
headerPosition: MatTabHeaderPosition;
50+
preserveContent: boolean;
4951
get selectedIndex(): number | null;
5052
set selectedIndex(value: number | null);
5153
readonly selectedIndexChange: EventEmitter<number>;
@@ -66,7 +68,7 @@ export declare abstract class _MatTabGroupBase extends _MatTabGroupMixinBase imp
6668
static ngAcceptInputType_disableRipple: BooleanInput;
6769
static ngAcceptInputType_dynamicHeight: BooleanInput;
6870
static ngAcceptInputType_selectedIndex: NumberInput;
69-
static ɵdir: i0.ɵɵDirectiveDefWithMeta<_MatTabGroupBase, never, never, { "dynamicHeight": "dynamicHeight"; "selectedIndex": "selectedIndex"; "headerPosition": "headerPosition"; "animationDuration": "animationDuration"; "disablePagination": "disablePagination"; "backgroundColor": "backgroundColor"; }, { "selectedIndexChange": "selectedIndexChange"; "focusChange": "focusChange"; "animationDone": "animationDone"; "selectedTabChange": "selectedTabChange"; }, never>;
71+
static ɵdir: i0.ɵɵDirectiveDefWithMeta<_MatTabGroupBase, never, never, { "dynamicHeight": "dynamicHeight"; "selectedIndex": "selectedIndex"; "headerPosition": "headerPosition"; "animationDuration": "animationDuration"; "disablePagination": "disablePagination"; "preserveContent": "preserveContent"; "backgroundColor": "backgroundColor"; }, { "selectedIndexChange": "selectedIndexChange"; "focusChange": "focusChange"; "animationDone": "animationDone"; "selectedTabChange": "selectedTabChange"; }, never>;
7072
static ɵfac: i0.ɵɵFactoryDef<_MatTabGroupBase, [null, null, { optional: true; }, { optional: true; }]>;
7173
}
7274

@@ -254,6 +256,7 @@ export interface MatTabsConfig {
254256
animationDuration?: string;
255257
disablePagination?: boolean;
256258
fitInkBarToContent?: boolean;
259+
preserveContent?: boolean;
257260
}
258261

259262
export declare class MatTabsModule {

0 commit comments

Comments
 (0)