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..f1dc0ccb0abc 100644 --- a/src/material/tabs/tab-body.ts +++ b/src/material/tabs/tab-body.ts @@ -6,36 +6,37 @@ * 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, + 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 +56,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 +87,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' - | 'left-origin-center' - | 'right-origin-center'; +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 MatTabBodyOriginState = 'left' | 'right'; /** * Wrapper for the contents of a tab. @@ -105,15 +115,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 _animationsModule = inject(ANIMATION_MODULE_TYPE, {optional: true}); + private _eventCleanups?: (() => void)[]; + 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 +142,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 +155,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. */ @@ -173,56 +194,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 +291,35 @@ 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'; + /** Whether animations are disabled for the tab group. */ + private _animationsDisabled() { + return ( + this._animationsModule === 'NoopAnimations' || + this.animationDuration === '0ms' || + this.animationDuration === '0s' + ); + } +} 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..e734d0680650 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,28 @@ 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; // (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 +222,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 +243,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 +257,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 +298,8 @@ export class MatTabGroup implements AfterContentInit, AfterContentChecked, OnDes // (undocumented) ngAfterContentInit(): void; // (undocumented) + ngAfterViewInit(): void; + // (undocumented) ngOnDestroy(): void; preserveContent: boolean; realignInkBar(): void; @@ -309,6 +311,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 +505,7 @@ export class MatTabNavPanel { static ɵfac: i0.ɵɵFactoryDeclaration; } -// @public +// @public @deprecated export const matTabsAnimations: { readonly translateTab: AnimationTriggerMetadata; };