From 3e152edbbddd4220eb9f24e2056cd84e67675974 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Thu, 9 Jan 2025 12:54:07 +0100 Subject: [PATCH] fix(material/tabs): switch away from animations module Reworks the tabs so they don't depend on the animations module anymore. This is both simpler and avoids some of the pitfalls of the animations module. --- src/material/tabs/tab-body.html | 15 +- src/material/tabs/tab-body.scss | 32 ++-- src/material/tabs/tab-body.spec.ts | 75 +-------- src/material/tabs/tab-body.ts | 196 ++++++++++++++++-------- src/material/tabs/tab-group.html | 16 +- src/material/tabs/tab-group.spec.ts | 60 +------- src/material/tabs/tab-group.ts | 44 +++++- src/material/tabs/tab.ts | 1 + src/material/tabs/tabs-animations.ts | 2 + tools/public_api_guard/material/tabs.md | 31 ++-- 10 files changed, 236 insertions(+), 236 deletions(-) diff --git a/src/material/tabs/tab-body.html b/src/material/tabs/tab-body.html index 9ad982213338..be8068e38f7d 100644 --- a/src/material/tabs/tab-body.html +++ b/src/material/tabs/tab-body.html @@ -1,10 +1,9 @@ -
+
diff --git a/src/material/tabs/tab-body.scss b/src/material/tabs/tab-body.scss index 5d24ace1da1d..4a589ccc2ead 100644 --- a/src/material/tabs/tab-body.scss +++ b/src/material/tabs/tab-body.scss @@ -27,19 +27,33 @@ .mat-mdc-tab-body-content { height: 100%; overflow: auto; + transform: none; + visibility: hidden; + + .mat-tab-body-animating > &, + .mat-mdc-tab-body-active > & { + visibility: visible; + } .mat-mdc-tab-group-dynamic-height & { overflow: hidden; } +} + +.mat-tab-body-content-can-animate { + // Note: there's a 1ms delay so that transition events + // still fire even if the duration is set to zero. + transition: transform var(--mat-tab-animation-duration) 1ms cubic-bezier(0.35, 0, 0.25, 1); - // 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 starting an animation before - // the previous one has finished. - &[style*='visibility: hidden'] { - display: none; + .mat-mdc-tab-body-wrapper._mat-animation-noopable & { + transition: none; } } + +.mat-tab-body-content-left { + transform: translate3d(-100%, 0, 0); +} + +.mat-tab-body-content-right { + transform: translate3d(100%, 0, 0); +} diff --git a/src/material/tabs/tab-body.spec.ts b/src/material/tabs/tab-body.spec.ts index 71ac2ffdb376..1f418618af67 100644 --- a/src/material/tabs/tab-body.spec.ts +++ b/src/material/tabs/tab-body.spec.ts @@ -36,74 +36,12 @@ describe('MatTabBody', () => { }); })); - describe('when initialized as center', () => { - let fixture: ComponentFixture; - - it('should be center position if origin is unchanged', () => { - fixture = TestBed.createComponent(SimpleTabBodyApp); - fixture.componentInstance.position = 0; - fixture.detectChanges(); - - expect(fixture.componentInstance.tabBody._position).toBe('center'); - }); - - it('should be center position if origin is explicitly set to null', () => { - fixture = TestBed.createComponent(SimpleTabBodyApp); - fixture.componentInstance.position = 0; - - // It can happen that the `origin` is explicitly set to null through the Angular input - // binding. This test should ensure that the body does properly such origin value. - // The `MatTab` class sets the origin by default to null. See related issue: #12455 - fixture.componentInstance.origin = null; - fixture.detectChanges(); - - expect(fixture.componentInstance.tabBody._position).toBe('center'); - }); - - describe('in LTR direction', () => { - beforeEach(() => { - dir = 'ltr'; - fixture = TestBed.createComponent(SimpleTabBodyApp); - }); - it('should be left-origin-center position with negative or zero origin', () => { - fixture.componentInstance.position = 0; - fixture.componentInstance.origin = 0; - fixture.detectChanges(); - - expect(fixture.componentInstance.tabBody._position).toBe('left-origin-center'); - }); - - it('should be right-origin-center position with positive nonzero origin', () => { - fixture.componentInstance.position = 0; - fixture.componentInstance.origin = 1; - fixture.detectChanges(); - - expect(fixture.componentInstance.tabBody._position).toBe('right-origin-center'); - }); - }); - - describe('in RTL direction', () => { - beforeEach(() => { - dir = 'rtl'; - fixture = TestBed.createComponent(SimpleTabBodyApp); - }); - - it('should be right-origin-center position with negative or zero origin', () => { - fixture.componentInstance.position = 0; - fixture.componentInstance.origin = 0; - fixture.detectChanges(); - - expect(fixture.componentInstance.tabBody._position).toBe('right-origin-center'); - }); - - it('should be left-origin-center position with positive nonzero origin', () => { - fixture.componentInstance.position = 0; - fixture.componentInstance.origin = 1; - fixture.detectChanges(); + it('should be center position if origin is unchanged', () => { + const fixture = TestBed.createComponent(SimpleTabBodyApp); + fixture.componentInstance.position = 0; + fixture.detectChanges(); - expect(fixture.componentInstance.tabBody._position).toBe('left-origin-center'); - }); - }); + expect(fixture.componentInstance.tabBody._position).toBe('center'); }); describe('should properly set the position in LTR', () => { @@ -213,14 +151,13 @@ describe('MatTabBody', () => { @Component({ template: ` Tab Body Content - + `, imports: [PortalModule, MatRippleModule, MatTabBody], }) class SimpleTabBodyApp implements AfterViewInit { content = signal(undefined); position: number; - origin: number | null; @ViewChild(MatTabBody) tabBody: MatTabBody; @ViewChild(TemplateRef) template: TemplateRef; diff --git a/src/material/tabs/tab-body.ts b/src/material/tabs/tab-body.ts index e9f3ecc5d325..0ddc4b7104ec 100644 --- a/src/material/tabs/tab-body.ts +++ b/src/material/tabs/tab-body.ts @@ -6,36 +6,38 @@ * found in the LICENSE file at https://angular.dev/license */ -import {AnimationEvent} from '@angular/animations'; import {Direction, Directionality} from '@angular/cdk/bidi'; import {CdkPortalOutlet, TemplatePortal} from '@angular/cdk/portal'; import {CdkScrollable} from '@angular/cdk/scrolling'; import { + ANIMATION_MODULE_TYPE, ChangeDetectionStrategy, ChangeDetectorRef, Component, Directive, ElementRef, EventEmitter, + Injector, Input, + NgZone, OnDestroy, OnInit, Output, + QueryList, + Renderer2, ViewChild, ViewEncapsulation, + afterNextRender, inject, } from '@angular/core'; -import {Subject, Subscription} from 'rxjs'; +import {Subscription} from 'rxjs'; import {startWith} from 'rxjs/operators'; -import {matTabsAnimations} from './tabs-animations'; /** * The portal host directive for the contents of the tab. * @docs-private */ -@Directive({ - selector: '[matTabBodyHost]', -}) +@Directive({selector: '[matTabBodyHost]'}) export class MatTabBodyPortal extends CdkPortalOutlet implements OnInit, OnDestroy { private _host = inject(MatTabBody); @@ -55,7 +57,7 @@ export class MatTabBodyPortal extends CdkPortalOutlet implements OnInit, OnDestr super.ngOnInit(); this._centeringSub = this._host._beforeCentering - .pipe(startWith(this._host._isCenterPosition(this._host._position))) + .pipe(startWith(this._host._isCenterPosition())) .subscribe((isCentering: boolean) => { if (this._host._content && isCentering && !this.hasAttached()) { this.attach(this._host._content); @@ -86,13 +88,22 @@ export class MatTabBodyPortal extends CdkPortalOutlet implements OnInit, OnDestr * In the case of a new tab body that should immediately be centered with an animating transition, * then left-origin-center or right-origin-center can be used, which will use left or right as its * pseudo-prior state. + * + * @deprecated Will stop being exported. + * @breaking-change 21.0.0 + */ +export type MatTabBodyPositionState = 'left' | 'center' | 'right'; + +/** + * The origin state is an internally used state that is set on a new tab body indicating if it + * began to the left or right of the prior selected index. For example, if the selected index was + * set to 1, and a new tab is created and selected at index 2, then the tab body would have an + * origin of right because its index was greater than the prior selected index. + * + * @deprecated No longer being used. Will be removed. + * @breaking-change 21.0.0 */ -export type MatTabBodyPositionState = - | 'left' - | 'center' - | 'right' - | 'left-origin-center' - | 'right-origin-center'; +export type MatTabBodyOriginState = 'left' | 'right'; /** * Wrapper for the contents of a tab. @@ -105,15 +116,26 @@ export type MatTabBodyPositionState = encapsulation: ViewEncapsulation.None, // tslint:disable-next-line:validate-decorators changeDetection: ChangeDetectionStrategy.Default, - animations: [matTabsAnimations.translateTab], host: { 'class': 'mat-mdc-tab-body', + // In most cases the `visibility: hidden` that we set on the off-screen content is enough + // to stop interactions with it, but if a child element sets its own `visibility`, it'll + // override the one from the parent. This ensures that even those elements will be removed + // from the accessibility tree. + '[attr.inert]': '_position === "center" ? null : ""', }, imports: [MatTabBodyPortal, CdkScrollable], }) export class MatTabBody implements OnInit, OnDestroy { private _elementRef = inject>(ElementRef); private _dir = inject(Directionality, {optional: true}); + private _ngZone = inject(NgZone); + private _injector = inject(Injector); + private _renderer = inject(Renderer2); + private _eventCleanups?: (() => void)[]; + private _animationsDisabled: boolean; + private _initialized: boolean; + private _fallbackTimer: ReturnType; /** Current position of the tab-body in the tab-group. Zero means that the tab is visible. */ private _positionIndex: number; @@ -121,11 +143,11 @@ export class MatTabBody implements OnInit, OnDestroy { /** Subscription to the directionality change observable. */ private _dirChangeSubscription = Subscription.EMPTY; - /** Tab body position state. Used by the animation trigger for the current state. */ + /** Current position of the body within the tab group. */ _position: MatTabBodyPositionState; - /** Emits when an animation on the tab is complete. */ - readonly _translateTabComplete = new Subject(); + /** Previous position of the body. */ + protected _previousPosition: MatTabBodyPositionState | undefined; /** Event emitted when the tab begins to animate towards the center as the active tab. */ @Output() readonly _onCentering: EventEmitter = new EventEmitter(); @@ -134,20 +156,20 @@ export class MatTabBody implements OnInit, OnDestroy { @Output() readonly _beforeCentering: EventEmitter = new EventEmitter(); /** Event emitted before the centering of the tab begins. */ - @Output() readonly _afterLeavingCenter: EventEmitter = new EventEmitter(); + readonly _afterLeavingCenter: EventEmitter = new EventEmitter(); /** Event emitted when the tab completes its animation towards the center. */ @Output() readonly _onCentered: EventEmitter = new EventEmitter(true); /** The portal host inside of this container into which the tab body content will be loaded. */ - @ViewChild(CdkPortalOutlet) _portalHost: CdkPortalOutlet; + @ViewChild(MatTabBodyPortal) _portalHost: MatTabBodyPortal; + + /** Element in which the content is rendered. */ + @ViewChild('content') _contentElement: ElementRef | undefined; /** The tab body content to display. */ @Input('content') _content: TemplatePortal; - /** Position that will be used when the tab is immediately becoming visible after creation. */ - @Input() origin: number | null; - // Note that the default value will always be overwritten by `MatTabBody`, but we need one // anyway to prevent the animations module from throwing an error if the body is used on its own. /** Duration for the tab's animation. */ @@ -156,6 +178,9 @@ export class MatTabBody implements OnInit, OnDestroy { /** Whether the tab's content should be kept in the DOM while it's off-screen. */ @Input() preserveContent: boolean = false; + /** List of all the tab bodies within the group. */ + @Input() siblings: QueryList | undefined; + /** The shifted index position of the tab body, where zero represents the active center tab. */ @Input() set position(position: number) { @@ -166,6 +191,8 @@ export class MatTabBody implements OnInit, OnDestroy { constructor(...args: unknown[]); constructor() { + this._animationsDisabled = inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations'; + if (this._dir) { const changeDetectorRef = inject(ChangeDetectorRef); this._dirChangeSubscription = this._dir.change.subscribe((dir: Direction) => { @@ -173,56 +200,96 @@ export class MatTabBody implements OnInit, OnDestroy { changeDetectorRef.markForCheck(); }); } - - this._translateTabComplete.subscribe(event => { - // If the transition to the center is complete, emit an event. - if (this._isCenterPosition(event.toState) && this._isCenterPosition(this._position)) { - this._onCentered.emit(); - } - - if (this._isCenterPosition(event.fromState) && !this._isCenterPosition(this._position)) { - this._afterLeavingCenter.emit(); - } - }); } - /** - * After initialized, check if the content is centered and has an origin. If so, set the - * special position states that transition the tab from the left or right before centering. - */ ngOnInit() { - if (this._position == 'center' && this.origin != null) { - this._position = this._computePositionFromOrigin(this.origin); + this._bindTransitionEvents(); + + if (this._position === 'center') { + this._setActiveClass(true); + + // Allows for the dynamic height to animate properly on the initial run. + afterNextRender(() => this._onCentering.emit(this._elementRef.nativeElement.clientHeight), { + injector: this._injector, + }); } + + this._initialized = true; } ngOnDestroy() { + clearTimeout(this._fallbackTimer); + this._eventCleanups?.forEach(cleanup => cleanup()); this._dirChangeSubscription.unsubscribe(); - this._translateTabComplete.complete(); } - _onTranslateTabStarted(event: AnimationEvent): void { - const isCentering = this._isCenterPosition(event.toState); + /** Sets up the transition events. */ + private _bindTransitionEvents() { + this._ngZone.runOutsideAngular(() => { + const element = this._elementRef.nativeElement; + const transitionDone = (event: TransitionEvent) => { + if (event.target === this._contentElement?.nativeElement) { + this._elementRef.nativeElement.classList.remove('mat-tab-body-animating'); + + // Only fire the actual callback when a transition is fully finished, + // otherwise the content can jump around when the next transition starts. + if (event.type === 'transitionend') { + this._transitionDone(); + } + } + }; + + this._eventCleanups = [ + this._renderer.listen(element, 'transitionstart', (event: TransitionEvent) => { + if (event.target === this._contentElement?.nativeElement) { + this._elementRef.nativeElement.classList.add('mat-tab-body-animating'); + this._transitionStarted(); + } + }), + this._renderer.listen(element, 'transitionend', transitionDone), + this._renderer.listen(element, 'transitioncancel', transitionDone), + ]; + }); + } + + /** Called when a transition has started. */ + private _transitionStarted() { + clearTimeout(this._fallbackTimer); + const isCentering = this._position === 'center'; this._beforeCentering.emit(isCentering); if (isCentering) { this._onCentering.emit(this._elementRef.nativeElement.clientHeight); } } + /** Called when a transition is done. */ + private _transitionDone() { + if (this._position === 'center') { + this._onCentered.emit(); + } else if (this._previousPosition === 'center') { + this._afterLeavingCenter.emit(); + } + } + + /** Sets the active styling on the tab body based on its current position. */ + _setActiveClass(isActive: boolean) { + this._elementRef.nativeElement.classList.toggle('mat-mdc-tab-body-active', isActive); + } + /** 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 { - return ( - position == 'center' || position == 'left-origin-center' || position == 'right-origin-center' - ); + _isCenterPosition(): boolean { + return this._positionIndex === 0; } /** Computes the position state that will be used for the tab-body animation trigger. */ private _computePositionAnimationState(dir: Direction = this._getLayoutDirection()) { + this._previousPosition = this._position; + if (this._positionIndex < 0) { this._position = dir == 'ltr' ? 'left' : 'right'; } else if (this._positionIndex > 0) { @@ -230,27 +297,26 @@ export class MatTabBody implements OnInit, OnDestroy { } else { this._position = 'center'; } - } - - /** - * Computes the position state based on the specified origin position. This is used if the - * tab is becoming visible immediately after creation. - */ - private _computePositionFromOrigin(origin: number): MatTabBodyPositionState { - const dir = this._getLayoutDirection(); - if ((dir == 'ltr' && origin <= 0) || (dir == 'rtl' && origin > 0)) { - return 'left-origin-center'; + if (this._animationsDisabled) { + this._simulateTransitionEvents(); + } else if ( + this._initialized && + (this._position === 'center' || this._previousPosition === 'center') + ) { + // The transition events are load-bearing and in some cases they might not fire (e.g. + // tests setting `* {transition: none}` to disable animations). This timeout will simulate + // them if a transition doesn't start within a certain amount of time. + clearTimeout(this._fallbackTimer); + this._fallbackTimer = this._ngZone.runOutsideAngular(() => + setTimeout(() => this._simulateTransitionEvents(), 100), + ); } + } - return 'right-origin-center'; + /** Simulates the body's transition events in an environment where they might not fire. */ + private _simulateTransitionEvents() { + this._transitionStarted(); + afterNextRender(() => this._transitionDone(), {injector: this._injector}); } } - -/** - * The origin state is an internally used state that is set on a new tab body indicating if it - * began to the left or right of the prior selected index. For example, if the selected index was - * set to 1, and a new tab is created and selected at index 2, then the tab body would have an - * origin of right because its index was greater than the prior selected index. - */ -export type MatTabBodyOriginState = 'left' | 'right'; diff --git a/src/material/tabs/tab-group.html b/src/material/tabs/tab-group.html index 731dd7cf6488..acd621732dfd 100644 --- a/src/material/tabs/tab-group.html +++ b/src/material/tabs/tab-group.html @@ -67,21 +67,19 @@ class="mat-mdc-tab-body-wrapper" [class._mat-animation-noopable]="_animationMode === 'NoopAnimations'" #tabBodyWrapper> - @for (tab of _tabs; track tab; let i = $index) { + @for (tab of _tabs; track tab;) { - + (_onCentering)="_setTabBodyWrapperHeight($event)" + (_beforeCentering)="_bodyCentered($event)"/> }
diff --git a/src/material/tabs/tab-group.spec.ts b/src/material/tabs/tab-group.spec.ts index f60b3c232df2..15f321f57370 100644 --- a/src/material/tabs/tab-group.spec.ts +++ b/src/material/tabs/tab-group.spec.ts @@ -557,41 +557,6 @@ describe('MatTabGroup', () => { fixture.detectChanges(); })); - it('should be able to add a new tab, select it, and have correct origin position', fakeAsync(() => { - const component: MatTabGroup = fixture.debugElement.query( - By.css('mat-tab-group'), - ).componentInstance; - - let tabs: MatTab[] = component._tabs.toArray(); - expect(tabs[0].origin).toBe(null); - expect(tabs[1].origin).toBe(0); - expect(tabs[2].origin).toBe(null); - - // Add a new tab on the right and select it, expect an origin >= than 0 (animate right) - fixture.componentInstance.tabs.push({label: 'New tab', content: 'to right of index'}); - fixture.componentInstance.selectedIndex = 4; - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); - tick(); - - tabs = component._tabs.toArray(); - expect(tabs[3].origin).toBeGreaterThanOrEqual(0); - - // Add a new tab in the beginning and select it, expect an origin < than 0 (animate left) - fixture.componentInstance.selectedIndex = 0; - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); - tick(); - - fixture.componentInstance.tabs.push({label: 'New tab', content: 'to left of index'}); - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); - tick(); - - tabs = component._tabs.toArray(); - expect(tabs[0].origin).toBeLessThan(0); - })); - it('should update selected index if the last tab removed while selected', fakeAsync(() => { const component: MatTabGroup = fixture.debugElement.query( By.css('mat-tab-group'), @@ -828,37 +793,22 @@ describe('MatTabGroup', () => { const contentElements: HTMLElement[] = Array.from( fixture.nativeElement.querySelectorAll('.mat-mdc-tab-body-content'), ); + const getVisibilities = () => + contentElements.map(element => getComputedStyle(element).visibility); - expect(contentElements.map(element => element.style.visibility)).toEqual([ - 'visible', - 'hidden', - 'hidden', - 'hidden', - ]); + expect(getVisibilities()).toEqual(['visible', 'hidden', 'hidden', 'hidden']); tabGroup.selectedIndex = 2; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); tick(); - - expect(contentElements.map(element => element.style.visibility)).toEqual([ - 'hidden', - 'hidden', - 'visible', - 'hidden', - ]); + expect(getVisibilities()).toEqual(['hidden', 'hidden', 'visible', 'hidden']); tabGroup.selectedIndex = 1; fixture.changeDetectorRef.markForCheck(); fixture.detectChanges(); tick(); - - expect(contentElements.map(element => element.style.visibility)).toEqual([ - 'hidden', - 'visible', - 'hidden', - 'hidden', - ]); + expect(getVisibilities()).toEqual(['hidden', 'visible', 'hidden', 'hidden']); })); }); diff --git a/src/material/tabs/tab-group.ts b/src/material/tabs/tab-group.ts index d585ea3d9cf2..9a928f78a85d 100644 --- a/src/material/tabs/tab-group.ts +++ b/src/material/tabs/tab-group.ts @@ -25,6 +25,9 @@ import { inject, numberAttribute, ANIMATION_MODULE_TYPE, + ViewChildren, + AfterViewInit, + NgZone, } from '@angular/core'; import {MAT_TAB_GROUP, MatTab} from './tab'; import {MatTabHeader} from './tab-header'; @@ -88,9 +91,16 @@ const ENABLE_BACKGROUND_INPUT = true; MatTabBody, ], }) -export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDestroy { +export class MatTabGroup + implements AfterViewInit, AfterContentInit, AfterContentChecked, OnDestroy +{ readonly _elementRef = inject(ElementRef); private _changeDetectorRef = inject(ChangeDetectorRef); + private _ngZone = inject(NgZone); + private _tabsSubscription = Subscription.EMPTY; + private _tabLabelSubscription = Subscription.EMPTY; + private _tabBodySubscription = Subscription.EMPTY; + _animationMode = inject(ANIMATION_MODULE_TYPE, {optional: true}); /** @@ -98,6 +108,7 @@ export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDes * inside the current one. We filter out only the tabs that belong to this group in `_tabs`. */ @ContentChildren(MatTab, {descendants: true}) _allTabs: QueryList; + @ViewChildren(MatTabBody) _tabBodies: QueryList | undefined; @ViewChild('tabBodyWrapper') _tabBodyWrapper: ElementRef; @ViewChild('tabHeader') _tabHeader: MatTabHeader; @@ -113,12 +124,6 @@ export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDes /** Snapshot of the height of the tab body wrapper before another tab is activated. */ private _tabBodyWrapperHeight: number = 0; - /** Subscription to tabs being added/removed. */ - private _tabsSubscription = Subscription.EMPTY; - - /** Subscription to changes in the tab labels. */ - private _tabLabelSubscription = Subscription.EMPTY; - /** * Theme color of the tab group. This API is supported in M2 themes only, it * has no effect in M3 themes. For color customization in M3, see https://material.angular.io/components/tabs/styling. @@ -396,6 +401,10 @@ export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDes }); } + ngAfterViewInit() { + this._tabBodySubscription = this._tabBodies!.changes.subscribe(() => this._bodyCentered(true)); + } + /** Listens to changes in all of the tabs. */ private _subscribeToAllTabChanges() { // Since we use a query with `descendants: true` to pick up the tabs, we may end up catching @@ -415,6 +424,7 @@ export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDes this._tabs.destroy(); this._tabsSubscription.unsubscribe(); this._tabLabelSubscription.unsubscribe(); + this._tabBodySubscription.unsubscribe(); } /** Re-aligns the ink bar to the selected tab element. */ @@ -503,6 +513,7 @@ export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDes */ _setTabBodyWrapperHeight(tabHeight: number): void { if (!this.dynamicHeight || !this._tabBodyWrapperHeight) { + this._tabBodyWrapperHeight = tabHeight; return; } @@ -522,7 +533,7 @@ export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDes const wrapper = this._tabBodyWrapper.nativeElement; this._tabBodyWrapperHeight = wrapper.clientHeight; wrapper.style.height = ''; - this.animationDone.emit(); + this._ngZone.run(() => this.animationDone.emit()); } /** Handle click events, setting new selected index if appropriate. */ @@ -550,6 +561,23 @@ export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDes this._tabHeader.focusIndex = index; } } + + /** + * Callback invoked when the centered state of a tab body changes. + * @param isCenter Whether the tab will be in the center. + */ + protected _bodyCentered(isCenter: boolean) { + // Marks all the existing tabs as inactive and the center tab as active. Note that this can + // be achieved much easier by using a class binding on each body. The problem with + // doing so is that we can't control the timing of when the class is removed from the + // previously-active element and added to the newly-active one. If there's a tick between + // removing the class and adding the new one, the content will jump in a very jarring way. + // We go through the trouble of setting the classes ourselves to guarantee that they're + // swapped out at the same time. + if (isCenter) { + this._tabBodies?.forEach((body, i) => body._setActiveClass(i === this._selectedIndex)); + } + } } /** A simple change event emitted on focus or selection changes. */ diff --git a/src/material/tabs/tab.ts b/src/material/tabs/tab.ts index 256f58ef77a7..e532e68a6ab1 100644 --- a/src/material/tabs/tab.ts +++ b/src/material/tabs/tab.ts @@ -116,6 +116,7 @@ export class MatTab implements OnInit, OnChanges, OnDestroy { */ position: number | null = null; + // TODO(crisbeto): we no longer use this, but some internal apps appear to rely on it. /** * The initial relatively index origin of the tab if it was created and selected after there * was already a selected tab. Provides context of what position the tab should originate from. diff --git a/src/material/tabs/tabs-animations.ts b/src/material/tabs/tabs-animations.ts index b2180de7e0b4..a1f6b3ec03bc 100644 --- a/src/material/tabs/tabs-animations.ts +++ b/src/material/tabs/tabs-animations.ts @@ -17,6 +17,8 @@ import { /** * Animations used by the Material tabs. * @docs-private + * @deprecated No longer used, will be removed. + * @breaking-change 21.0.0. */ export const matTabsAnimations: { readonly translateTab: AnimationTriggerMetadata; diff --git a/tools/public_api_guard/material/tabs.md b/tools/public_api_guard/material/tabs.md index 258c23ead76f..1a7cf4e2a018 100644 --- a/tools/public_api_guard/material/tabs.md +++ b/tools/public_api_guard/material/tabs.md @@ -7,7 +7,6 @@ import { AfterContentChecked } from '@angular/core'; import { AfterContentInit } from '@angular/core'; import { AfterViewInit } from '@angular/core'; -import { AnimationEvent as AnimationEvent_2 } from '@angular/animations'; import { AnimationTriggerMetadata } from '@angular/animations'; import { BehaviorSubject } from 'rxjs'; import { CdkPortal } from '@angular/cdk/portal'; @@ -188,28 +187,29 @@ export class MatTabBody implements OnInit, OnDestroy { animationDuration: string; readonly _beforeCentering: EventEmitter; _content: TemplatePortal; + _contentElement: ElementRef | undefined; _getLayoutDirection(): Direction; - _isCenterPosition(position: MatTabBodyPositionState | string): boolean; + _isCenterPosition(): boolean; // (undocumented) ngOnDestroy(): void; + // (undocumented) ngOnInit(): void; readonly _onCentered: EventEmitter; readonly _onCentering: EventEmitter; - // (undocumented) - _onTranslateTabStarted(event: AnimationEvent_2): void; - origin: number | null; - _portalHost: CdkPortalOutlet; + _portalHost: MatTabBodyPortal; set position(position: number); _position: MatTabBodyPositionState; preserveContent: boolean; - readonly _translateTabComplete: Subject; + protected _previousPosition: MatTabBodyPositionState | undefined; + _setActiveClass(isActive: boolean): void; + siblings: QueryList | undefined; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } -// @public +// @public @deprecated export type MatTabBodyOriginState = 'left' | 'right'; // @public @@ -223,8 +223,8 @@ export class MatTabBodyPortal extends CdkPortalOutlet implements OnInit, OnDestr static ɵfac: i0.ɵɵFactoryDeclaration; } -// @public -export type MatTabBodyPositionState = 'left' | 'center' | 'right' | 'left-origin-center' | 'right-origin-center'; +// @public @deprecated +export type MatTabBodyPositionState = 'left' | 'center' | 'right'; // @public export class MatTabChangeEvent { @@ -244,7 +244,7 @@ export class MatTabContent { } // @public -export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDestroy { +export class MatTabGroup implements AfterViewInit, AfterContentInit, AfterContentChecked, OnDestroy { constructor(...args: unknown[]); alignTabs: string | null; _allTabs: QueryList; @@ -258,6 +258,7 @@ export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDes // @deprecated get backgroundColor(): ThemePalette; set backgroundColor(value: ThemePalette); + protected _bodyCentered(isCenter: boolean): void; color: ThemePalette; get contentTabIndex(): number | null; set contentTabIndex(value: number); @@ -298,6 +299,8 @@ export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDes // (undocumented) ngAfterContentInit(): void; // (undocumented) + ngAfterViewInit(): void; + // (undocumented) ngOnDestroy(): void; preserveContent: boolean; realignInkBar(): void; @@ -309,6 +312,8 @@ export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDes _setTabBodyWrapperHeight(tabHeight: number): void; stretchTabs: boolean; // (undocumented) + _tabBodies: QueryList | undefined; + // (undocumented) _tabBodyWrapper: ElementRef; _tabFocusChanged(focusOrigin: FocusOrigin, index: number): void; // (undocumented) @@ -501,7 +506,7 @@ export class MatTabNavPanel { static ɵfac: i0.ɵɵFactoryDeclaration; } -// @public +// @public @deprecated export const matTabsAnimations: { readonly translateTab: AnimationTriggerMetadata; };