Skip to content

Commit fcd0d55

Browse files
authored
refactor(module:tabs): use native animation (#9610)
1 parent 74df7b6 commit fcd0d55

9 files changed

Lines changed: 184 additions & 113 deletions

File tree

components/core/animation/no-animation.spec.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
88
import { provideNoopAnimations } from '@angular/platform-browser/animations';
99

1010
import {
11+
isAnimationEnabled,
1112
NZ_NO_ANIMATION_CLASS,
1213
NzNoAnimationDirective,
1314
provideNzNoAnimation,
@@ -21,6 +22,7 @@ import {
2122
class TestComponent {
2223
noAnimation = signal(false);
2324
animationClass = withAnimationCheck(() => 'animation-class');
25+
animationEnabled = isAnimationEnabled(() => !this.noAnimation());
2426
}
2527

2628
describe('NzNoAnimationDirective', () => {
@@ -93,9 +95,47 @@ describe('provideNzNoAnimation', () => {
9395
});
9496
});
9597

98+
describe('isAnimationEnabled', () => {
99+
it('should return passed getter when animations are enabled', () => {
100+
const fixture = TestBed.createComponent(TestComponent);
101+
expect(fixture.componentInstance.animationEnabled()).toBe(true);
102+
103+
fixture.componentInstance.noAnimation.set(true);
104+
expect(fixture.componentInstance.animationEnabled()).toBe(false);
105+
});
106+
107+
it('should return false with provideNoopAnimations', async () => {
108+
TestBed.configureTestingModule({
109+
providers: [provideNoopAnimations()]
110+
});
111+
const fixture = TestBed.createComponent(TestComponent);
112+
expect(fixture.componentInstance.animationEnabled()).toBe(false);
113+
});
114+
115+
it('should return false with provideNzNoAnimation', async () => {
116+
TestBed.configureTestingModule({
117+
providers: [provideNzNoAnimation()]
118+
});
119+
const fixture = TestBed.createComponent(TestComponent);
120+
expect(fixture.componentInstance.animationEnabled()).toBe(false);
121+
});
122+
123+
describe('injection context validation', () => {
124+
it('should throw error when called outside injection context in dev mode', () => {
125+
const originalDevMode = (globalThis as { ngDevMode?: boolean }).ngDevMode;
126+
(globalThis as { ngDevMode?: boolean }).ngDevMode = true;
127+
128+
expect(() => {
129+
isAnimationEnabled(() => true);
130+
}).toThrow();
131+
132+
(globalThis as { ngDevMode?: boolean }).ngDevMode = originalDevMode;
133+
});
134+
});
135+
});
136+
96137
describe('withAnimationCheck', () => {
97138
it('should return dynamic class when animations are enabled', () => {
98-
TestBed.configureTestingModule({});
99139
const fixture = TestBed.createComponent(TestComponent);
100140
fixture.detectChanges();
101141

components/core/animation/no-animation.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,23 @@ export function provideNzNoAnimation(): Provider[] {
5252
];
5353
}
5454

55+
function _internalAnimationEnabled(): boolean {
56+
return inject(ANIMATION_MODULE_TYPE, { optional: true }) !== 'NoopAnimations';
57+
}
58+
59+
/**
60+
* If the current animation mode is `NoopAnimations`, returns the false as a signal.
61+
* Otherwise, returns the result of the provided getter as a computed signal.
62+
* @param getter A function that returns the outer logic for whether animations are enabled.
63+
*/
64+
export function isAnimationEnabled(getter: () => boolean): Signal<boolean> {
65+
if (typeof ngDevMode !== 'undefined' && ngDevMode) {
66+
assertInInjectionContext(isAnimationEnabled);
67+
}
68+
69+
return _internalAnimationEnabled() ? computed(getter) : signal(false);
70+
}
71+
5572
/**
5673
* If the current animation mode is `NoopAnimations`, returns the no-animation class as a signal.
5774
* Otherwise, returns the result of the provided class name getter as a computed signal.
@@ -62,6 +79,5 @@ export function withAnimationCheck(classNameGetter: () => string): Signal<string
6279
assertInInjectionContext(withAnimationCheck);
6380
}
6481

65-
const animationType = inject(ANIMATION_MODULE_TYPE, { optional: true });
66-
return animationType === 'NoopAnimations' ? signal(NZ_NO_ANIMATION_CLASS) : computed(classNameGetter);
82+
return _internalAnimationEnabled() ? computed(classNameGetter) : signal(NZ_NO_ANIMATION_CLASS);
6783
}

components/core/animation/public-api.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ export * from './collapse';
88
export * from './move';
99
export * from './notification';
1010
export * from './slide';
11-
export * from './tabs';
1211
export * from './thumb';
1312
export * from './zoom';
1413
export * from './no-animation';

components/core/animation/tabs.ts

Lines changed: 0 additions & 41 deletions
This file was deleted.

components/tabs/style/index.less

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323

2424
.@{tab-prefix-cls}-nav-wrap {
2525
position: relative;
26-
display: inline-block;
2726
display: flex;
2827
flex: auto;
2928
align-self: stretch;

components/tabs/tab-body.component.ts

Lines changed: 91 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,104 @@
44
*/
55

66
import { NgTemplateOutlet } from '@angular/common';
7-
import { ChangeDetectionStrategy, Component, Input, TemplateRef, ViewEncapsulation } from '@angular/core';
7+
import {
8+
ChangeDetectionStrategy,
9+
Component,
10+
computed,
11+
effect,
12+
ElementRef,
13+
inject,
14+
input,
15+
signal,
16+
TemplateRef,
17+
untracked,
18+
ViewEncapsulation
19+
} from '@angular/core';
820

9-
import { tabSwitchMotion } from 'ng-zorro-antd/core/animation';
21+
import { isAnimationEnabled } from 'ng-zorro-antd/core/animation';
22+
import { requestAnimationFrame } from 'ng-zorro-antd/core/polyfill';
23+
import { generateClassName } from 'ng-zorro-antd/core/util';
24+
25+
type AnimationState = 'enter-start' | 'enter-active' | 'leave-start' | 'leave-active' | 'void' | 'hidden';
26+
27+
const CLASS_NAME = 'ant-tabs-tabpane';
28+
const ANIMATION_PREFIX = 'ant-tabs-switch';
29+
const ANIMATION_CLASS_MAP: Record<AnimationState, string[]> = {
30+
'enter-start': [generateClassName(ANIMATION_PREFIX, 'enter'), generateClassName(ANIMATION_PREFIX, 'enter-start')],
31+
'enter-active': [generateClassName(ANIMATION_PREFIX, 'enter'), generateClassName(ANIMATION_PREFIX, 'enter-active')],
32+
'leave-start': [generateClassName(ANIMATION_PREFIX, 'leave'), generateClassName(ANIMATION_PREFIX, 'leave-start')],
33+
'leave-active': [generateClassName(ANIMATION_PREFIX, 'leave'), generateClassName(ANIMATION_PREFIX, 'leave-active')],
34+
// If animation is enabled, we should hide the tabpane after the leave animation is done
35+
hidden: [generateClassName(CLASS_NAME, 'hidden')],
36+
void: []
37+
};
1038

1139
@Component({
1240
selector: '[nz-tab-body]',
1341
exportAs: 'nzTabBody',
14-
encapsulation: ViewEncapsulation.None,
15-
changeDetection: ChangeDetectionStrategy.OnPush,
16-
template: `<ng-template [ngTemplateOutlet]="content"></ng-template>`,
42+
imports: [NgTemplateOutlet],
43+
template: `<ng-template [ngTemplateOutlet]="content()"></ng-template>`,
1744
host: {
18-
class: 'ant-tabs-tabpane',
19-
'[class.ant-tabs-tabpane-active]': 'active',
20-
'[class.ant-tabs-tabpane-hidden]': 'animated ? null : !active',
21-
'[attr.tabindex]': 'active ? 0 : -1',
22-
'[attr.aria-hidden]': '!active',
23-
'[style.overflow-y]': 'animated ? active ? null : "none" : null',
24-
'[@tabSwitchMotion]': `active ? 'enter' : 'leave'`,
25-
'[@.disabled]': `!animated`
45+
'[class]': 'class()',
46+
'[class.ant-tabs-tabpane-active]': 'active()',
47+
'[attr.tabindex]': 'active() ? 0 : -1',
48+
'[attr.aria-hidden]': '!active()',
49+
'(transitionend)': '_onTransitionEnd($event)'
2650
},
27-
imports: [NgTemplateOutlet],
28-
animations: [tabSwitchMotion]
51+
encapsulation: ViewEncapsulation.None,
52+
changeDetection: ChangeDetectionStrategy.OnPush
2953
})
3054
export class NzTabBodyComponent {
31-
@Input() content: TemplateRef<void> | null = null;
32-
@Input() active = false;
33-
@Input() animated = true;
55+
private readonly elementRef = inject(ElementRef);
56+
57+
readonly content = input<TemplateRef<void> | null>(null);
58+
readonly active = input(false);
59+
readonly animated = input(true);
60+
61+
protected readonly _animationState = signal<AnimationState>('void');
62+
protected readonly _animationEnabled = isAnimationEnabled(() => this.animated());
63+
64+
protected readonly class = computed(() => {
65+
const cls: string[] = [CLASS_NAME];
66+
if (this._animationEnabled()) {
67+
cls.push(...ANIMATION_CLASS_MAP[this._animationState()]);
68+
} else if (!this.active()) {
69+
cls.push(generateClassName(CLASS_NAME, 'hidden'));
70+
}
71+
return cls;
72+
});
73+
74+
constructor() {
75+
effect(() => {
76+
if (!this._animationEnabled()) {
77+
return;
78+
}
79+
80+
if (!this.active()) {
81+
untracked(() => this._animationState.set('leave-start'));
82+
requestAnimationFrame(() => {
83+
this._animationState.set('leave-active');
84+
});
85+
} else {
86+
untracked(() => this._animationState.set('enter-start'));
87+
requestAnimationFrame(() => {
88+
this._animationState.set('enter-active');
89+
});
90+
}
91+
});
92+
}
93+
94+
protected _onTransitionEnd(event: TransitionEvent): void {
95+
// avoid event bubbling from child elements
96+
if (event.target !== this.elementRef.nativeElement) {
97+
return;
98+
}
99+
100+
const currentState = this._animationState();
101+
if (currentState === 'enter-active') {
102+
this._animationState.set('void');
103+
} else if (currentState === 'leave-active') {
104+
this._animationState.set('hidden');
105+
}
106+
}
34107
}

components/tabs/tab-nav-bar.component.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,13 @@ const CSS_TRANSFORM_TIME = 150;
5555
@Component({
5656
selector: 'nz-tabs-nav',
5757
exportAs: 'nzTabsNav',
58-
changeDetection: ChangeDetectionStrategy.OnPush,
59-
encapsulation: ViewEncapsulation.None,
58+
imports: [
59+
NzTabScrollListDirective,
60+
NzTabAddButtonComponent,
61+
NzTabsInkBarDirective,
62+
NzTabNavOperationComponent,
63+
NgTemplateOutlet
64+
],
6065
template: `
6166
@if (startExtraContent()) {
6267
<div class="ant-tabs-extra-content">
@@ -113,13 +118,8 @@ const CSS_TRANSFORM_TIME = 150;
113118
class: 'ant-tabs-nav',
114119
'(keydown)': 'handleKeydown($event)'
115120
},
116-
imports: [
117-
NzTabScrollListDirective,
118-
NzTabAddButtonComponent,
119-
NzTabsInkBarDirective,
120-
NzTabNavOperationComponent,
121-
NgTemplateOutlet
122-
]
121+
changeDetection: ChangeDetectionStrategy.OnPush,
122+
encapsulation: ViewEncapsulation.None
123123
})
124124
export class NzTabNavBarComponent implements AfterViewInit, AfterContentChecked, OnChanges {
125125
private cdr = inject(ChangeDetectorRef);

components/tabs/tabs-ink-bar.directive.ts

Lines changed: 14 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
* found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE
44
*/
55

6-
import { ANIMATION_MODULE_TYPE, Directive, ElementRef, Input, NgZone, inject } from '@angular/core';
6+
import { coerceCssPixelValue } from '@angular/cdk/coercion';
7+
import { Directive, ElementRef, NgZone, inject, input } from '@angular/core';
78

9+
import { isAnimationEnabled } from 'ng-zorro-antd/core/animation';
810
import { requestAnimationFrame } from 'ng-zorro-antd/core/polyfill';
911

1012
import { NzTabPositionMode } from './interfaces';
@@ -13,20 +15,16 @@ import { NzTabPositionMode } from './interfaces';
1315
selector: 'nz-tabs-ink-bar, [nz-tabs-ink-bar]',
1416
host: {
1517
class: 'ant-tabs-ink-bar',
16-
'[class.ant-tabs-ink-bar-animated]': '_animated'
18+
'[class.ant-tabs-ink-bar-animated]': 'animationEnabled()'
1719
}
1820
})
1921
export class NzTabsInkBarDirective {
20-
private ngZone = inject(NgZone);
21-
private el: HTMLElement = inject(ElementRef<HTMLElement>).nativeElement;
22+
private readonly ngZone = inject(NgZone);
23+
private readonly el = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
2224

23-
@Input() position: NzTabPositionMode = 'horizontal';
24-
@Input() animated = true;
25-
26-
animationMode = inject(ANIMATION_MODULE_TYPE, { optional: true });
27-
get _animated(): boolean {
28-
return this.animationMode !== 'NoopAnimations' && this.animated;
29-
}
25+
readonly position = input<NzTabPositionMode>('horizontal');
26+
readonly animated = input(true);
27+
protected readonly animationEnabled = isAnimationEnabled(() => this.animated());
3028

3129
alignToElement(element: HTMLElement): void {
3230
this.ngZone.runOutsideAngular(() => {
@@ -35,32 +33,16 @@ export class NzTabsInkBarDirective {
3533
}
3634

3735
setStyles(element: HTMLElement): void {
38-
if (this.position === 'horizontal') {
36+
if (this.position() === 'horizontal') {
3937
this.el.style.top = '';
4038
this.el.style.height = '';
41-
this.el.style.left = this.getLeftPosition(element);
42-
this.el.style.width = this.getElementWidth(element);
39+
this.el.style.left = coerceCssPixelValue(element?.offsetLeft || 0);
40+
this.el.style.width = coerceCssPixelValue(element?.offsetWidth || 0);
4341
} else {
4442
this.el.style.left = '';
4543
this.el.style.width = '';
46-
this.el.style.top = this.getTopPosition(element);
47-
this.el.style.height = this.getElementHeight(element);
44+
this.el.style.top = coerceCssPixelValue(element?.offsetTop || 0);
45+
this.el.style.height = coerceCssPixelValue(element?.offsetHeight || 0);
4846
}
4947
}
50-
51-
getLeftPosition(element: HTMLElement): string {
52-
return element ? `${element.offsetLeft || 0}px` : '0';
53-
}
54-
55-
getElementWidth(element: HTMLElement): string {
56-
return element ? `${element.offsetWidth || 0}px` : '0';
57-
}
58-
59-
getTopPosition(element: HTMLElement): string {
60-
return element ? `${element.offsetTop || 0}px` : '0';
61-
}
62-
63-
getElementHeight(element: HTMLElement): string {
64-
return element ? `${element.offsetHeight || 0}px` : '0';
65-
}
6648
}

0 commit comments

Comments
 (0)