From cf3a976416453b533e5d9ce1eed58368b2c3d5ad Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 28 Jan 2022 07:24:25 +0100 Subject: [PATCH] feat(material/tabs): add the ability to keep content inside the DOM while off-screen **Note:** this is a resubmit of #20393 with a minor adjustment in the animation config to fix the issue that caused it to be reverted due to an error in Firefox tests. 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. --- .../material/tabs/index.ts | 3 ++ .../tab-group-preserve-content-example.html | 14 +++++ .../tab-group-preserve-content-example.ts | 10 ++++ .../mdc-tabs/tab-body.scss | 11 ++++ .../mdc-tabs/tab-group.html | 1 + .../mdc-tabs/tab-group.spec.ts | 53 ++++++++++++++++++- src/material/tabs/tab-body.scss | 11 ++++ src/material/tabs/tab-body.ts | 7 ++- src/material/tabs/tab-config.ts | 7 +++ src/material/tabs/tab-group.html | 1 + src/material/tabs/tab-group.spec.ts | 53 ++++++++++++++++++- src/material/tabs/tab-group.ts | 9 ++++ src/material/tabs/tabs-animations.ts | 27 ++++++++-- src/material/tabs/tabs.md | 9 ++++ tools/public_api_guard/material/tabs.md | 7 ++- 15 files changed, 213 insertions(+), 10 deletions(-) create mode 100644 src/components-examples/material/tabs/tab-group-preserve-content/tab-group-preserve-content-example.html create mode 100644 src/components-examples/material/tabs/tab-group-preserve-content/tab-group-preserve-content-example.ts 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..5e7955d4a373 100644 --- a/src/material/tabs/tabs-animations.ts +++ b/src/material/tabs/tabs-animations.ts @@ -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)'), ]), ]), 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 `