diff --git a/src/components-examples/material/tabs/index.ts b/src/components-examples/material/tabs/index.ts index cb5279b3487b..60a688c946e5 100644 --- a/src/components-examples/material/tabs/index.ts +++ b/src/components-examples/material/tabs/index.ts @@ -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'; @@ -37,6 +38,7 @@ export { TabGroupThemeExample, TabNavBarBasicExample, TabNavBarWithPanelExample, + TabGroupPreserveContentExample, }; const EXAMPLES = [ @@ -54,6 +56,7 @@ const EXAMPLES = [ TabGroupThemeExample, TabNavBarBasicExample, TabNavBarWithPanelExample, + TabGroupPreserveContentExample, ]; @NgModule({ diff --git a/src/components-examples/material/tabs/tab-group-preserve-content/tab-group-preserve-content-example.html b/src/components-examples/material/tabs/tab-group-preserve-content/tab-group-preserve-content-example.html new file mode 100644 index 000000000000..3d58eef2e816 --- /dev/null +++ b/src/components-examples/material/tabs/tab-group-preserve-content/tab-group-preserve-content-example.html @@ -0,0 +1,14 @@ +

Start the video in the first tab and navigate to the second one to see how it keeps playing.

+ + + + + + Note how the video from the previous tab is still playing. + diff --git a/src/components-examples/material/tabs/tab-group-preserve-content/tab-group-preserve-content-example.ts b/src/components-examples/material/tabs/tab-group-preserve-content/tab-group-preserve-content-example.ts new file mode 100644 index 000000000000..8509b2d1fd04 --- /dev/null +++ b/src/components-examples/material/tabs/tab-group-preserve-content/tab-group-preserve-content-example.ts @@ -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 {} diff --git a/src/material-experimental/mdc-tabs/tab-body.scss b/src/material-experimental/mdc-tabs/tab-body.scss index 164ed84102fb..fd7ff38c9d55 100644 --- a/src/material-experimental/mdc-tabs/tab-body.scss +++ b/src/material-experimental/mdc-tabs/tab-body.scss @@ -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 { diff --git a/src/material-experimental/mdc-tabs/tab-group.html b/src/material-experimental/mdc-tabs/tab-group.html index 4fa26e85c807..157fe938c340 100644 --- a/src/material-experimental/mdc-tabs/tab-group.html +++ b/src/material-experimental/mdc-tabs/tab-group.html @@ -63,6 +63,7 @@ [position]="tab.position!" [origin]="tab.origin" [animationDuration]="animationDuration" + [preserveContent]="preserveContent" (_onCentered)="_removeTabBodyWrapperHeight()" (_onCentering)="_setTabBodyWrapperHeight($event)"> diff --git a/src/material-experimental/mdc-tabs/tab-group.spec.ts b/src/material-experimental/mdc-tabs/tab-group.spec.ts index af5b9b55e0b4..dacd95956c01 100644 --- a/src/material-experimental/mdc-tabs/tab-group.spec.ts +++ b/src/material-experimental/mdc-tabs/tab-group.spec.ts @@ -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', () => { @@ -1126,7 +1176,7 @@ class AsyncTabsTestApp implements OnInit { @Component({ template: ` - + Pizza, fries Broccoli, spinach {{otherContent}} @@ -1135,6 +1185,7 @@ class AsyncTabsTestApp implements OnInit { `, }) class TabGroupWithSimpleApi { + preserveContent = false; otherLabel = 'Fruit'; otherContent = 'Apples, grapes'; @ViewChild('legumes') legumes: any; diff --git a/src/material/tabs/tab-body.scss b/src/material/tabs/tab-body.scss index a92aaf492e20..ff6a49468662 100644 --- a/src/material/tabs/tab-body.scss +++ b/src/material/tabs/tab-body.scss @@ -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; + } } diff --git a/src/material/tabs/tab-body.ts b/src/material/tabs/tab-body.ts index 731f7380b22e..36b2129f11a4 100644 --- a/src/material/tabs/tab-body.ts +++ b/src/material/tabs/tab-body.ts @@ -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(); + } }); } @@ -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) { diff --git a/src/material/tabs/tab-config.ts b/src/material/tabs/tab-config.ts index 5c6711aa2c40..f974e1d480e9 100644 --- a/src/material/tabs/tab-config.ts +++ b/src/material/tabs/tab-config.ts @@ -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. */ diff --git a/src/material/tabs/tab-group.html b/src/material/tabs/tab-group.html index ebd767eda5b7..c39e3528f2a0 100644 --- a/src/material/tabs/tab-group.html +++ b/src/material/tabs/tab-group.html @@ -50,6 +50,7 @@ [position]="tab.position!" [origin]="tab.origin" [animationDuration]="animationDuration" + [preserveContent]="preserveContent" (_onCentered)="_removeTabBodyWrapperHeight()" (_onCentering)="_setTabBodyWrapperHeight($event)"> diff --git a/src/material/tabs/tab-group.spec.ts b/src/material/tabs/tab-group.spec.ts index 6cb53a529583..1be121f13985 100644 --- a/src/material/tabs/tab-group.spec.ts +++ b/src/material/tabs/tab-group.spec.ts @@ -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', () => { @@ -1072,7 +1122,7 @@ class AsyncTabsTestApp implements OnInit { @Component({ template: ` - + Pizza, fries Broccoli, spinach {{otherContent}} @@ -1081,6 +1131,7 @@ class AsyncTabsTestApp implements OnInit { `, }) class TabGroupWithSimpleApi { + preserveContent = false; otherLabel = 'Fruit'; otherContent = 'Apples, grapes'; @ViewChild('legumes') legumes: any; diff --git a/src/material/tabs/tab-group.ts b/src/material/tabs/tab-group.ts index 7952dabdaa51..11b077499bc8 100644 --- a/src/material/tabs/tab-group.ts +++ b/src/material/tabs/tab-group.ts @@ -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 { @@ -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; } /** diff --git a/src/material/tabs/tabs-animations.ts b/src/material/tabs/tabs-animations.ts index 2b338f126f83..0468870cb90f 100644 --- a/src/material/tabs/tabs-animations.ts +++ b/src/material/tabs/tabs-animations.ts @@ -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)'), ]), ]), diff --git a/src/material/tabs/tabs.md b/src/material/tabs/tabs.md index 31ae1e7423b5..08d83e6227c2 100644 --- a/src/material/tabs/tabs.md +++ b/src/material/tabs/tabs.md @@ -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 `` 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 `