Skip to content

Commit

Permalink
feat(material/tabs): add the ability to keep content inside the DOM w…
Browse files Browse the repository at this point in the history
…hile 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.
  • Loading branch information
crisbeto committed Jan 27, 2022
1 parent 5a00027 commit c3188c8
Show file tree
Hide file tree
Showing 15 changed files with 221 additions and 11 deletions.
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
36 changes: 30 additions & 6 deletions src/material/tabs/tabs-animations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,50 @@ 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.
state('center, void, left-origin-center, right-origin-center', style({transform: 'none'})),
state(
'center, void, left-origin-center, right-origin-center',
style({
// Transitions to `none` instead of 0, because some browsers might blur the content.
transform: 'none',
// Ensures that the `visibility: hidden` from below is cleared.
visibility: '',
}),
),

// 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

0 comments on commit c3188c8

Please sign in to comment.