Skip to content

feat(material/tabs): add the ability to keep content inside the DOM while off-screen #24299

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 28, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components-examples/material/tabs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {TabGroupHarnessExample} from './tab-group-harness/tab-group-harness-exam
import {TabGroupDynamicExample} from './tab-group-dynamic/tab-group-dynamic-example';
import {TabGroupHeaderBelowExample} from './tab-group-header-below/tab-group-header-below-example';
import {TabGroupLazyLoadedExample} from './tab-group-lazy-loaded/tab-group-lazy-loaded-example';
import {TabGroupPreserveContentExample} from './tab-group-preserve-content/tab-group-preserve-content-example';
import {TabGroupStretchedExample} from './tab-group-stretched/tab-group-stretched-example';
import {TabGroupThemeExample} from './tab-group-theme/tab-group-theme-example';
import {TabNavBarBasicExample} from './tab-nav-bar-basic/tab-nav-bar-basic-example';
Expand All @@ -37,6 +38,7 @@ export {
TabGroupThemeExample,
TabNavBarBasicExample,
TabNavBarWithPanelExample,
TabGroupPreserveContentExample,
};

const EXAMPLES = [
Expand All @@ -54,6 +56,7 @@ const EXAMPLES = [
TabGroupThemeExample,
TabNavBarBasicExample,
TabNavBarWithPanelExample,
TabGroupPreserveContentExample,
];

@NgModule({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<p>Start the video in the first tab and navigate to the second one to see how it keeps playing.</p>

<mat-tab-group [preserveContent]="true">
<mat-tab label="First">
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/B-lipaiZII8"
frameborder="0"
allow="accelerometer; autoplay; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen></iframe>
</mat-tab>
<mat-tab label="Second">Note how the video from the previous tab is still playing.</mat-tab>
</mat-tab-group>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import {Component} from '@angular/core';

/**
* @title Tab group that keeps its content inside the DOM when it's off-screen.
*/
@Component({
selector: 'tab-group-preserve-content-example',
templateUrl: 'tab-group-preserve-content-example.html',
})
export class TabGroupPreserveContentExample {}
11 changes: 11 additions & 0 deletions src/material-experimental/mdc-tabs/tab-body.scss
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@
.mat-mdc-tab-group.mat-mdc-tab-group-dynamic-height &.mat-mdc-tab-body-active {
overflow-y: hidden;
}

// Usually the `visibility: hidden` added by the animation is enough to prevent focus from
// entering the collapsed content, but children with their own `visibility` can override it.
// This is a fallback that completely hides the content when the element becomes hidden.
// Note that we can't do this in the animation definition, because the style gets recomputed too
// late, breaking the animation because Angular didn't have time to figure out the target height.
// This can also be achieved with JS, but it has issues when when starting an animation before
// the previous one has finished.
&[style*='visibility: hidden'] {
display: none;
}
}

.mat-mdc-tab-body-content {
Expand Down
1 change: 1 addition & 0 deletions src/material-experimental/mdc-tabs/tab-group.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
[position]="tab.position!"
[origin]="tab.origin"
[animationDuration]="animationDuration"
[preserveContent]="preserveContent"
(_onCentered)="_removeTabBodyWrapperHeight()"
(_onCentering)="_setTabBodyWrapperHeight($event)">
</mat-tab-body>
Expand Down
53 changes: 52 additions & 1 deletion src/material-experimental/mdc-tabs/tab-group.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,56 @@ describe('MDC-based MatTabGroup', () => {

expect(tabGroupNode.classList).toContain('mat-mdc-tab-group-inverted-header');
});

it('should be able to opt into keeping the inactive tab content in the DOM', fakeAsync(() => {
fixture.componentInstance.preserveContent = true;
fixture.detectChanges();

expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
expect(fixture.nativeElement.textContent).not.toContain('Peanuts');

tabGroup.selectedIndex = 3;
fixture.detectChanges();
tick();

expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
expect(fixture.nativeElement.textContent).toContain('Peanuts');
}));

it('should visibly hide the content of inactive tabs', fakeAsync(() => {
const contentElements: HTMLElement[] = Array.from(
fixture.nativeElement.querySelectorAll('.mat-mdc-tab-body-content'),
);

expect(contentElements.map(element => element.style.visibility)).toEqual([
'',
'hidden',
'hidden',
'hidden',
]);

tabGroup.selectedIndex = 2;
fixture.detectChanges();
tick();

expect(contentElements.map(element => element.style.visibility)).toEqual([
'hidden',
'hidden',
'',
'hidden',
]);

tabGroup.selectedIndex = 1;
fixture.detectChanges();
tick();

expect(contentElements.map(element => element.style.visibility)).toEqual([
'hidden',
'',
'hidden',
'hidden',
]);
}));
});

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

@Component({
template: `
<mat-tab-group>
<mat-tab-group [preserveContent]="preserveContent">
<mat-tab label="Junk food"> Pizza, fries </mat-tab>
<mat-tab label="Vegetables"> Broccoli, spinach </mat-tab>
<mat-tab [label]="otherLabel"> {{otherContent}} </mat-tab>
Expand All @@ -1135,6 +1185,7 @@ class AsyncTabsTestApp implements OnInit {
`,
})
class TabGroupWithSimpleApi {
preserveContent = false;
otherLabel = 'Fruit';
otherContent = 'Apples, grapes';
@ViewChild('legumes') legumes: any;
Expand Down
11 changes: 11 additions & 0 deletions src/material/tabs/tab-body.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,15 @@
.mat-tab-group-dynamic-height & {
overflow: hidden;
}

// Usually the `visibility: hidden` added by the animation is enough to prevent focus from
// entering the collapsed content, but children with their own `visibility` can override it.
// This is a fallback that completely hides the content when the element becomes hidden.
// Note that we can't do this in the animation definition, because the style gets recomputed too
// late, breaking the animation because Angular didn't have time to figure out the target height.
// This can also be achieved with JS, but it has issues when when starting an animation before
// the previous one has finished.
&[style*='visibility: hidden'] {
display: none;
}
}
7 changes: 6 additions & 1 deletion src/material/tabs/tab-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ export class MatTabBodyPortal extends CdkPortalOutlet implements OnInit, OnDestr
});

this._leavingSub = this._host._afterLeavingCenter.subscribe(() => {
this.detach();
if (!this._host.preserveContent) {
this.detach();
}
});
}

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

/** Whether the tab's content should be kept in the DOM while it's off-screen. */
@Input() preserveContent: boolean = false;

/** The shifted index position of the tab body, where zero represents the active center tab. */
@Input()
set position(position: number) {
Expand Down
7 changes: 7 additions & 0 deletions src/material/tabs/tab-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ export interface MatTabsConfig {

/** `tabindex` to be set on the inner element that wraps the tab content. */
contentTabIndex?: number;

/**
* By default tabs remove their content from the DOM while it's off-screen.
* Setting this to `true` will keep it in the DOM which will prevent elements
* like iframes and videos from reloading next time it comes back into the view.
*/
preserveContent?: boolean;
}

/** Injection token that can be used to provide the default options the tabs module. */
Expand Down
1 change: 1 addition & 0 deletions src/material/tabs/tab-group.html
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
[position]="tab.position!"
[origin]="tab.origin"
[animationDuration]="animationDuration"
[preserveContent]="preserveContent"
(_onCentered)="_removeTabBodyWrapperHeight()"
(_onCentering)="_setTabBodyWrapperHeight($event)">
</mat-tab-body>
Expand Down
53 changes: 52 additions & 1 deletion src/material/tabs/tab-group.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,56 @@ describe('MatTabGroup', () => {

expect(tabGroupNode.classList).toContain('mat-tab-group-inverted-header');
});

it('should be able to opt into keeping the inactive tab content in the DOM', fakeAsync(() => {
fixture.componentInstance.preserveContent = true;
fixture.detectChanges();

expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
expect(fixture.nativeElement.textContent).not.toContain('Peanuts');

tabGroup.selectedIndex = 3;
fixture.detectChanges();
tick();

expect(fixture.nativeElement.textContent).toContain('Pizza, fries');
expect(fixture.nativeElement.textContent).toContain('Peanuts');
}));

it('should visibly hide the content of inactive tabs', fakeAsync(() => {
const contentElements: HTMLElement[] = Array.from(
fixture.nativeElement.querySelectorAll('.mat-tab-body-content'),
);

expect(contentElements.map(element => element.style.visibility)).toEqual([
'',
'hidden',
'hidden',
'hidden',
]);

tabGroup.selectedIndex = 2;
fixture.detectChanges();
tick();

expect(contentElements.map(element => element.style.visibility)).toEqual([
'hidden',
'hidden',
'',
'hidden',
]);

tabGroup.selectedIndex = 1;
fixture.detectChanges();
tick();

expect(contentElements.map(element => element.style.visibility)).toEqual([
'hidden',
'',
'hidden',
'hidden',
]);
}));
});

describe('lazy loaded tabs', () => {
Expand Down Expand Up @@ -1072,7 +1122,7 @@ class AsyncTabsTestApp implements OnInit {

@Component({
template: `
<mat-tab-group>
<mat-tab-group [preserveContent]="preserveContent">
<mat-tab label="Junk food"> Pizza, fries </mat-tab>
<mat-tab label="Vegetables"> Broccoli, spinach </mat-tab>
<mat-tab [label]="otherLabel"> {{otherContent}} </mat-tab>
Expand All @@ -1081,6 +1131,7 @@ class AsyncTabsTestApp implements OnInit {
`,
})
class TabGroupWithSimpleApi {
preserveContent = false;
otherLabel = 'Fruit';
otherContent = 'Apples, grapes';
@ViewChild('legumes') legumes: any;
Expand Down
9 changes: 9 additions & 0 deletions src/material/tabs/tab-group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,14 @@ export abstract class _MatTabGroupBase
@Input()
disablePagination: boolean;

/**
* By default tabs remove their content from the DOM while it's off-screen.
* Setting this to `true` will keep it in the DOM which will prevent elements
* like iframes and videos from reloading next time it comes back into the view.
*/
@Input()
preserveContent: boolean;

/** Background color of the tab group. */
@Input()
get backgroundColor(): ThemePalette {
Expand Down Expand Up @@ -214,6 +222,7 @@ export abstract class _MatTabGroupBase
this.dynamicHeight =
defaultConfig && defaultConfig.dynamicHeight != null ? defaultConfig.dynamicHeight : false;
this.contentTabIndex = defaultConfig?.contentTabIndex ?? null;
this.preserveContent = !!defaultConfig?.preserveContent;
}

/**
Expand Down
27 changes: 22 additions & 5 deletions src/material/tabs/tabs-animations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,43 @@ export const matTabsAnimations: {
} = {
/** Animation translates a tab along the X axis. */
translateTab: trigger('translateTab', [
// Note: transitions to `none` instead of 0, because some browsers might blur the content.
// Transitions to `none` instead of 0, because some browsers might blur the content.
state('center, void, left-origin-center, right-origin-center', style({transform: 'none'})),

// If the tab is either on the left or right, we additionally add a `min-height` of 1px
// in order to ensure that the element has a height before its state changes. This is
// necessary because Chrome does seem to skip the transition in RTL mode if the element does
// not have a static height and is not rendered. See related issue: #9465
state('left', style({transform: 'translate3d(-100%, 0, 0)', minHeight: '1px'})),
state('right', style({transform: 'translate3d(100%, 0, 0)', minHeight: '1px'})),
state(
'left',
style({
transform: 'translate3d(-100%, 0, 0)',
minHeight: '1px',

// Normally this is redundant since we detach the content from the DOM, but if the user
// opted into keeping the content in the DOM, we have to hide it so it isn't focusable.
visibility: 'hidden',
}),
),
state(
'right',
style({
transform: 'translate3d(100%, 0, 0)',
minHeight: '1px',
visibility: 'hidden',
}),
),

transition(
'* => left, * => right, left => center, right => center',
animate('{{animationDuration}} cubic-bezier(0.35, 0, 0.25, 1)'),
),
transition('void => left-origin-center', [
style({transform: 'translate3d(-100%, 0, 0)'}),
style({transform: 'translate3d(-100%, 0, 0)', visibility: 'hidden'}),
animate('{{animationDuration}} cubic-bezier(0.35, 0, 0.25, 1)'),
]),
transition('void => right-origin-center', [
style({transform: 'translate3d(100%, 0, 0)'}),
style({transform: 'translate3d(100%, 0, 0)', visibility: 'hidden'}),
animate('{{animationDuration}} cubic-bezier(0.35, 0, 0.25, 1)'),
]),
]),
Expand Down
9 changes: 9 additions & 0 deletions src/material/tabs/tabs.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ duration can be configured globally using the `MAT_TABS_CONFIG` injection token.
"file": "tab-group-animations-example.html",
"region": "slow-animation-duration"}) -->

### Keeping the tab content inside the DOM while it's off-screen
By default the `<mat-tab-group>` will remove the content of off-screen tabs from the DOM until they
come into the view. This is optimal for most cases since it keeps the DOM size smaller, but it
isn't great for others like when a tab has an `<audio>` or `<video>` element, because the content
will be re-initialized whenever the user navigates to the tab. If you want to keep the content of
off-screen tabs in the DOM, you can set the `preserveContent` input to `true`.

<!-- example(tab-group-preserve-content) -->

### Accessibility
`MatTabGroup` and `MatTabNavBar` implement different interaction patterns for different use-cases.
You should choose the component that works best for your application.
Expand Down
Loading