diff --git a/src/lib/tabs/tab-body.ts b/src/lib/tabs/tab-body.ts index 2073372b560a..5bb29fcc7c0c 100644 --- a/src/lib/tabs/tab-body.ts +++ b/src/lib/tabs/tab-body.ts @@ -24,6 +24,8 @@ import { ViewContainerRef, forwardRef, ViewChild, + OnChanges, + SimpleChanges, } from '@angular/core'; import {AnimationEvent} from '@angular/animations'; import {TemplatePortal, CdkPortalOutlet, PortalHostDirective} from '@angular/cdk/portal'; @@ -82,6 +84,7 @@ export class MatTabBodyPortal extends CdkPortalOutlet implements OnInit, OnDestr .subscribe((isCentering: boolean) => { if (isCentering && !this.hasAttached()) { this.attach(this._host._content); + this._host._restoreScrollPosition(); } }); @@ -112,9 +115,10 @@ export class MatTabBodyPortal extends CdkPortalOutlet implements OnInit, OnDestr animations: [matTabsAnimations.translateTab], host: { 'class': 'mat-tab-body', + '[class.mat-tab-body-active]': 'active', }, }) -export class MatTabBody implements OnInit, OnDestroy { +export class MatTabBody implements OnInit, OnChanges, OnDestroy { /** Current position of the tab-body in the tab-group. Zero means that the tab is visible. */ private _positionIndex: number; @@ -122,12 +126,18 @@ export class MatTabBody implements OnInit, OnDestroy { /** Subscription to the directionality change observable. */ private _dirChangeSubscription = Subscription.EMPTY; + /** Scroll position of the tab before the user switched away. */ + private _lastScrollPosition = 0; + /** Tab body position state. Used by the animation trigger for the current state. */ _position: MatTabBodyPositionState; /** Emits when an animation on the tab is complete. */ _translateTabComplete = new Subject(); + /** Element wrapping the tab's content. */ + @ViewChild('content') _contentElement: ElementRef; + /** Event emitted when the tab begins to animate towards the center as the active tab. */ @Output() readonly _onCentering: EventEmitter = new EventEmitter(); @@ -154,6 +164,9 @@ export class MatTabBody implements OnInit, OnDestroy { /** Duration for the tab's animation. */ @Input() animationDuration: string = '500ms'; + /** Whether the tab is currently active. */ + @Input() active: boolean; + /** The shifted index position of the tab body, where zero represents the active center tab. */ @Input() set position(position: number) { @@ -214,16 +227,36 @@ export class MatTabBody implements OnInit, OnDestroy { } } + ngOnChanges(changes: SimpleChanges) { + // Cache the scroll position before moving away from the tab. Note that this has to be done + // through change detection and as early as possible, because some browsers (namely Safari) + // will reset the scroll position when we switch from an absolute to a relative position. + if (changes.active && changes.active.previousValue) { + this._lastScrollPosition = this._elementRef.nativeElement.scrollTop || + this._contentElement.nativeElement.scrollTop; + } + } + /** The text direction of the containing app. */ _getLayoutDirection(): Direction { return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr'; } /** Whether the provided position state is considered center, regardless of origin. */ - _isCenterPosition(position: MatTabBodyPositionState|string): boolean { + _isCenterPosition(position: MatTabBodyPositionState | string): boolean { return position == 'center' || - position == 'left-origin-center' || - position == 'right-origin-center'; + position == 'left-origin-center' || + position == 'right-origin-center'; + } + + _restoreScrollPosition() { + if (this._lastScrollPosition) { + // Depending on the browser, the scrollable element can end up being + // either the host element or the element with all the content. + this._contentElement.nativeElement.scrollTop = + this._elementRef.nativeElement.scrollTop = + this._lastScrollPosition; + } } /** Computes the position state that will be used for the tab-body animation trigger. */ diff --git a/src/lib/tabs/tab-group.html b/src/lib/tabs/tab-group.html index a72e9451a471..ae54d337833d 100644 --- a/src/lib/tabs/tab-group.html +++ b/src/lib/tabs/tab-group.html @@ -36,7 +36,7 @@ *ngFor="let tab of _tabs; let i = index" [id]="_getTabContentId(i)" [attr.aria-labelledby]="_getTabLabelId(i)" - [class.mat-tab-body-active]="selectedIndex == i" + [active]="selectedIndex === i" [content]="tab.content" [position]="tab.position" [origin]="tab.origin" diff --git a/src/lib/tabs/tab-group.spec.ts b/src/lib/tabs/tab-group.spec.ts index f783f7e11239..361c1dfda780 100644 --- a/src/lib/tabs/tab-group.spec.ts +++ b/src/lib/tabs/tab-group.spec.ts @@ -478,6 +478,45 @@ describe('MatTabGroup', () => { expect(fixture.componentInstance.handleSelection).not.toHaveBeenCalled(); })); + it('should preserve the scroll position when switching between tabs', fakeAsync(() => { + const testComponent = fixture.componentInstance; + + // Add a lot of content to make one of the tabs scrollable. + testComponent.tabs[1].content = new Array(500).fill('content!').join('\n\n'); + fixture.detectChanges(); + tick(500); + + // Cap the tab group height. + fixture.debugElement.query(By.css('mat-tab-group')).nativeElement.style.height = `300px`; + + const tabElements = fixture.debugElement.queryAll(By.css('.mat-tab-body-content')) + .map(debugElement => debugElement.nativeElement as HTMLElement); + + // Focus the tab with the extra content. + testComponent.selectedIndex = 1; + fixture.detectChanges(); + tick(500); + + // Ensure that there is content and scroll down 100px. + expect(tabElements[1].offsetHeight).toBeGreaterThan(0, 'Expected tab to have some content.'); + + // Handle some differences in the way browsers determine what element is scrollable. + tabElements[1].scrollTop = tabElements[1].parentElement!.scrollTop = 100; + + // Move to another tab. + testComponent.selectedIndex = 0; + fixture.detectChanges(); + tick(500); + + // Switch back to the tab with the extra content. + testComponent.selectedIndex = 1; + fixture.detectChanges(); + tick(500); + + expect(tabElements[1].scrollTop || tabElements[1].parentElement!.scrollTop) + .toBe(100, 'Expected scroll position to be restored.'); + })); + }); describe('async tabs', () => { diff --git a/src/material-examples/tab-group-scrollable/tab-group-scrollable-example.css b/src/material-examples/tab-group-scrollable/tab-group-scrollable-example.css new file mode 100644 index 000000000000..65a2cc800edb --- /dev/null +++ b/src/material-examples/tab-group-scrollable/tab-group-scrollable-example.css @@ -0,0 +1,12 @@ +mat-tab-group { + height: 400px; +} + +.example-content-box { + height: 100px; + width: 300px; + margin: 8px; + display: flex; + align-items: center; + justify-content: center; +} diff --git a/src/material-examples/tab-group-scrollable/tab-group-scrollable-example.html b/src/material-examples/tab-group-scrollable/tab-group-scrollable-example.html new file mode 100644 index 000000000000..fcba9e92e62a --- /dev/null +++ b/src/material-examples/tab-group-scrollable/tab-group-scrollable-example.html @@ -0,0 +1,7 @@ + + +
+ {{ i }} +
+
+
diff --git a/src/material-examples/tab-group-scrollable/tab-group-scrollable-example.ts b/src/material-examples/tab-group-scrollable/tab-group-scrollable-example.ts new file mode 100644 index 000000000000..abca4bdac094 --- /dev/null +++ b/src/material-examples/tab-group-scrollable/tab-group-scrollable-example.ts @@ -0,0 +1,14 @@ +import {Component} from '@angular/core'; + +/** + * @title Tab group with scrollable content + */ +@Component({ + selector: 'tab-group-scrollable-example', + templateUrl: 'tab-group-scrollable-example.html', + styleUrls: ['tab-group-scrollable-example.css'], +}) +export class TabGroupScrollableExample { + tabs = ['First', 'Second', 'Third']; + boxes = new Array(100); +} diff --git a/tools/public_api_guard/lib/tabs.d.ts b/tools/public_api_guard/lib/tabs.d.ts index 05f64f44ec0b..14e7e3e841ae 100644 --- a/tools/public_api_guard/lib/tabs.d.ts +++ b/tools/public_api_guard/lib/tabs.d.ts @@ -36,15 +36,17 @@ export declare class MatTab extends _MatTabMixinBase implements OnInit, CanDisab ngOnInit(): void; } -export declare class MatTabBody implements OnInit, OnDestroy { +export declare class MatTabBody implements OnInit, OnChanges, OnDestroy { readonly _afterLeavingCenter: EventEmitter; readonly _beforeCentering: EventEmitter; _content: TemplatePortal; + _contentElement: ElementRef; readonly _onCentered: EventEmitter; readonly _onCentering: EventEmitter; _portalHost: PortalHostDirective; _position: MatTabBodyPositionState; _translateTabComplete: Subject; + active: boolean; animationDuration: string; origin: number; position: number; @@ -53,6 +55,8 @@ export declare class MatTabBody implements OnInit, OnDestroy { _getLayoutDirection(): Direction; _isCenterPosition(position: MatTabBodyPositionState | string): boolean; _onTranslateTabStarted(event: AnimationEvent): void; + _restoreScrollPosition(): void; + ngOnChanges(changes: SimpleChanges): void; ngOnDestroy(): void; ngOnInit(): void; }