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