mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
603 lines
20 KiB
TypeScript
603 lines
20 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.dev/license
|
|
*/
|
|
import {
|
|
animate,
|
|
AnimationPlayer,
|
|
AnimationTriggerMetadata,
|
|
style,
|
|
transition,
|
|
trigger,
|
|
} from '@angular/animations';
|
|
import {
|
|
ɵAnimationEngine as AnimationEngine,
|
|
ɵAnimationRendererFactory as AnimationRendererFactory,
|
|
} from '@angular/animations/browser';
|
|
import {
|
|
APP_INITIALIZER,
|
|
Component,
|
|
destroyPlatform,
|
|
importProvidersFrom,
|
|
Injectable,
|
|
NgModule,
|
|
NgZone,
|
|
provideZoneChangeDetection,
|
|
RendererFactory2,
|
|
RendererType2,
|
|
ViewChild,
|
|
} from '@angular/core';
|
|
import {TestBed} from '@angular/core/testing';
|
|
import {bootstrapApplication, platformBrowser} from '../../index';
|
|
import {
|
|
BrowserAnimationsModule,
|
|
ɵInjectableAnimationEngine as InjectableAnimationEngine,
|
|
} from '../index';
|
|
import {provideAnimationsAsync} from '../async';
|
|
import {DomRendererFactory2} from '../../src/dom/dom_renderer';
|
|
import {withBody, isNode, el} from '@angular/private/testing';
|
|
|
|
(function () {
|
|
if (isNode) return;
|
|
describe('AnimationRenderer', () => {
|
|
let element: any;
|
|
beforeEach(() => {
|
|
element = el('<div></div>');
|
|
|
|
TestBed.configureTestingModule({
|
|
providers: [{provide: AnimationEngine, useClass: MockAnimationEngine}],
|
|
imports: [BrowserAnimationsModule],
|
|
});
|
|
});
|
|
|
|
function makeRenderer(animationTriggers: any[] = []) {
|
|
const type = <RendererType2>{
|
|
id: 'id',
|
|
encapsulation: null!,
|
|
styles: [],
|
|
data: {'animation': animationTriggers},
|
|
};
|
|
return (TestBed.inject(RendererFactory2) as AnimationRendererFactory).createRenderer(
|
|
element,
|
|
type,
|
|
);
|
|
}
|
|
|
|
it("should hook into the engine's insert operations when appending children", () => {
|
|
const renderer = makeRenderer();
|
|
const engine = TestBed.inject(AnimationEngine) as MockAnimationEngine;
|
|
const container = el('<div></div>');
|
|
|
|
renderer.appendChild(container, element);
|
|
expect(engine.captures['onInsert'].pop()).toEqual([element]);
|
|
});
|
|
|
|
it("should hook into the engine's insert operations when inserting a child before another", () => {
|
|
const renderer = makeRenderer();
|
|
const engine = TestBed.inject(AnimationEngine) as MockAnimationEngine;
|
|
const container = el('<div></div>');
|
|
const element2 = el('<div></div>');
|
|
container.appendChild(element2);
|
|
|
|
renderer.insertBefore(container, element, element2);
|
|
expect(engine.captures['onInsert'].pop()).toEqual([element]);
|
|
});
|
|
|
|
it("should hook into the engine's insert operations when removing children", () => {
|
|
const renderer = makeRenderer();
|
|
const engine = TestBed.inject(AnimationEngine) as MockAnimationEngine;
|
|
|
|
renderer.removeChild(null, element);
|
|
expect(engine.captures['onRemove'].pop()).toEqual([element]);
|
|
});
|
|
|
|
it("should hook into the engine's setProperty call if the property begins with `@`", () => {
|
|
const renderer = makeRenderer();
|
|
const engine = TestBed.inject(AnimationEngine) as MockAnimationEngine;
|
|
|
|
renderer.setProperty(element, 'prop', 'value');
|
|
expect(engine.captures['setProperty']).toBeFalsy();
|
|
|
|
renderer.setProperty(element, '@prop', 'value');
|
|
expect(engine.captures['setProperty'].pop()).toEqual([element, 'prop', 'value']);
|
|
});
|
|
|
|
// https://github.com/angular/angular/issues/32794
|
|
it('should support nested animation triggers', () => {
|
|
makeRenderer([[trigger('myAnimation', [])]]);
|
|
|
|
const {triggers} = TestBed.inject(AnimationEngine) as MockAnimationEngine;
|
|
|
|
expect(triggers.length).toEqual(1);
|
|
expect(triggers[0].name).toEqual('myAnimation');
|
|
});
|
|
|
|
describe('listen', () => {
|
|
it("should hook into the engine's listen call if the property begins with `@`", () => {
|
|
const renderer = makeRenderer();
|
|
const engine = TestBed.inject(AnimationEngine) as MockAnimationEngine;
|
|
|
|
const cb = (event: any): boolean => {
|
|
return true;
|
|
};
|
|
|
|
renderer.listen(element, 'event', cb);
|
|
expect(engine.captures['listen']).toBeFalsy();
|
|
|
|
renderer.listen(element, '@event.phase', cb);
|
|
expect(engine.captures['listen'].pop()).toEqual([element, 'event', 'phase']);
|
|
});
|
|
|
|
it('should resolve the body|document|window nodes given their values as strings as input', () => {
|
|
const renderer = makeRenderer();
|
|
const engine = TestBed.inject(AnimationEngine) as MockAnimationEngine;
|
|
|
|
const cb = (event: any): boolean => {
|
|
return true;
|
|
};
|
|
|
|
renderer.listen('body', '@event', cb);
|
|
expect(engine.captures['listen'].pop()[0]).toBe(document.body);
|
|
|
|
renderer.listen('document', '@event', cb);
|
|
expect(engine.captures['listen'].pop()[0]).toBe(document);
|
|
|
|
renderer.listen('window', '@event', cb);
|
|
expect(engine.captures['listen'].pop()[0]).toBe(window);
|
|
});
|
|
});
|
|
|
|
describe('registering animations', () => {
|
|
it('should only create a trigger definition once even if the registered multiple times');
|
|
});
|
|
|
|
describe('flushing animations', () => {
|
|
// these tests are only meant to be run within the DOM
|
|
if (isNode) return;
|
|
|
|
it('should flush and fire callbacks when the zone becomes stable', (async) => {
|
|
@Component({
|
|
selector: 'my-cmp',
|
|
template: '<div [@myAnimation]="exp" (@myAnimation.start)="onStart($event)"></div>',
|
|
animations: [
|
|
trigger('myAnimation', [
|
|
transition('* => state', [
|
|
style({'opacity': '0'}),
|
|
animate(500, style({'opacity': '1'})),
|
|
]),
|
|
]),
|
|
],
|
|
standalone: false,
|
|
})
|
|
class Cmp {
|
|
exp: any;
|
|
event: any;
|
|
onStart(event: any) {
|
|
this.event = event;
|
|
}
|
|
}
|
|
|
|
TestBed.configureTestingModule({
|
|
providers: [{provide: AnimationEngine, useClass: InjectableAnimationEngine}],
|
|
declarations: [Cmp],
|
|
});
|
|
|
|
const engine = TestBed.inject(AnimationEngine);
|
|
const fixture = TestBed.createComponent(Cmp);
|
|
const cmp = fixture.componentInstance;
|
|
cmp.exp = 'state';
|
|
fixture.detectChanges();
|
|
fixture.whenStable().then(() => {
|
|
expect(cmp.event.triggerName).toEqual('myAnimation');
|
|
expect(cmp.event.phaseName).toEqual('start');
|
|
cmp.event = null;
|
|
|
|
engine.flush();
|
|
expect(cmp.event).toBeFalsy();
|
|
async();
|
|
});
|
|
});
|
|
|
|
it('should properly insert/remove nodes through the animation renderer that do not contain animations', (async) => {
|
|
@Component({
|
|
selector: 'my-cmp',
|
|
template: '<div #elm *ngIf="exp"></div>',
|
|
animations: [
|
|
trigger('someAnimation', [
|
|
transition('* => *', [
|
|
style({'opacity': '0'}),
|
|
animate(500, style({'opacity': '1'})),
|
|
]),
|
|
]),
|
|
],
|
|
standalone: false,
|
|
})
|
|
class Cmp {
|
|
exp: any;
|
|
@ViewChild('elm') public element: any;
|
|
}
|
|
|
|
TestBed.configureTestingModule({
|
|
providers: [
|
|
{provide: AnimationEngine, useClass: InjectableAnimationEngine},
|
|
provideZoneChangeDetection(),
|
|
],
|
|
declarations: [Cmp],
|
|
});
|
|
|
|
const fixture = TestBed.createComponent(Cmp);
|
|
const cmp = fixture.componentInstance;
|
|
cmp.exp = true;
|
|
fixture.detectChanges();
|
|
|
|
fixture.whenStable().then(() => {
|
|
cmp.exp = false;
|
|
const element = cmp.element;
|
|
expect(element.nativeElement.parentNode).toBeTruthy();
|
|
|
|
fixture.detectChanges();
|
|
fixture.whenStable().then(() => {
|
|
expect(element.nativeElement.parentNode).toBeFalsy();
|
|
async();
|
|
});
|
|
});
|
|
});
|
|
|
|
it('should only queue up dom removals if the element itself contains a valid leave animation', () => {
|
|
@Component({
|
|
selector: 'my-cmp',
|
|
template: `
|
|
<div #elm1 *ngIf="exp1"></div>
|
|
<div #elm2 @animation1 *ngIf="exp2"></div>
|
|
<div #elm3 @animation2 *ngIf="exp3"></div>
|
|
`,
|
|
animations: [
|
|
trigger('animation1', [transition('a => b', [])]),
|
|
trigger('animation2', [transition(':leave', [])]),
|
|
],
|
|
standalone: false,
|
|
})
|
|
class Cmp {
|
|
exp1: any = true;
|
|
exp2: any = true;
|
|
exp3: any = true;
|
|
|
|
@ViewChild('elm1') public elm1: any;
|
|
|
|
@ViewChild('elm2') public elm2: any;
|
|
|
|
@ViewChild('elm3') public elm3: any;
|
|
}
|
|
|
|
TestBed.configureTestingModule({
|
|
providers: [
|
|
{provide: AnimationEngine, useClass: InjectableAnimationEngine},
|
|
provideZoneChangeDetection(),
|
|
],
|
|
declarations: [Cmp],
|
|
});
|
|
|
|
const engine = TestBed.inject(AnimationEngine);
|
|
const fixture = TestBed.createComponent(Cmp);
|
|
const cmp = fixture.componentInstance;
|
|
|
|
fixture.detectChanges();
|
|
const elm1 = cmp.elm1;
|
|
const elm2 = cmp.elm2;
|
|
const elm3 = cmp.elm3;
|
|
assertHasParent(elm1);
|
|
assertHasParent(elm2);
|
|
assertHasParent(elm3);
|
|
engine.flush();
|
|
finishPlayers(engine.players);
|
|
|
|
cmp.exp1 = false;
|
|
fixture.detectChanges();
|
|
assertHasParent(elm1, false);
|
|
assertHasParent(elm2);
|
|
assertHasParent(elm3);
|
|
engine.flush();
|
|
expect(engine.players.length).toEqual(0);
|
|
|
|
cmp.exp2 = false;
|
|
fixture.detectChanges();
|
|
assertHasParent(elm1, false);
|
|
assertHasParent(elm2, false);
|
|
assertHasParent(elm3);
|
|
engine.flush();
|
|
expect(engine.players.length).toEqual(0);
|
|
|
|
cmp.exp3 = false;
|
|
fixture.detectChanges();
|
|
assertHasParent(elm1, false);
|
|
assertHasParent(elm2, false);
|
|
assertHasParent(elm3);
|
|
engine.flush();
|
|
expect(engine.players.length).toEqual(1);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('AnimationRendererFactory', () => {
|
|
beforeEach(() => {
|
|
TestBed.configureTestingModule({
|
|
providers: [
|
|
{
|
|
provide: RendererFactory2,
|
|
useClass: ExtendedAnimationRendererFactory,
|
|
deps: [DomRendererFactory2, AnimationEngine, NgZone],
|
|
},
|
|
],
|
|
imports: [BrowserAnimationsModule],
|
|
});
|
|
});
|
|
|
|
it('should provide hooks at the start and end of change detection', () => {
|
|
@Component({
|
|
selector: 'my-cmp',
|
|
template: `
|
|
<div [@myAnimation]="exp"></div>
|
|
`,
|
|
animations: [trigger('myAnimation', [])],
|
|
standalone: false,
|
|
})
|
|
class Cmp {
|
|
public exp: any;
|
|
}
|
|
|
|
TestBed.configureTestingModule({
|
|
providers: [{provide: AnimationEngine, useClass: InjectableAnimationEngine}],
|
|
declarations: [Cmp],
|
|
});
|
|
|
|
const renderer = TestBed.inject(RendererFactory2) as ExtendedAnimationRendererFactory;
|
|
const fixture = TestBed.createComponent(Cmp);
|
|
const cmp = fixture.componentInstance;
|
|
|
|
renderer.log = [];
|
|
fixture.changeDetectorRef.detectChanges();
|
|
expect(renderer.log).toEqual(['begin', 'end']);
|
|
|
|
renderer.log = [];
|
|
fixture.changeDetectorRef.detectChanges();
|
|
expect(renderer.log).toEqual(['begin', 'end']);
|
|
});
|
|
});
|
|
|
|
describe('destroy', () => {
|
|
beforeEach(destroyPlatform);
|
|
afterEach(destroyPlatform);
|
|
|
|
// See https://github.com/angular/angular/issues/39955
|
|
it(
|
|
'should clear bootstrapped component contents when the `BrowserAnimationsModule` is imported',
|
|
withBody('<div>before</div><app-root></app-root><div>after</div>', async () => {
|
|
@Component({
|
|
selector: 'app-root',
|
|
template: 'app-root content',
|
|
standalone: false,
|
|
})
|
|
class AppComponent {}
|
|
|
|
@NgModule({
|
|
imports: [BrowserAnimationsModule],
|
|
declarations: [AppComponent],
|
|
bootstrap: [AppComponent],
|
|
})
|
|
class AppModule {}
|
|
|
|
const ngModuleRef = await platformBrowser().bootstrapModule(AppModule);
|
|
|
|
const root = document.body.querySelector('app-root')!;
|
|
expect(root.textContent).toEqual('app-root content');
|
|
expect(document.body.childNodes.length).toEqual(3);
|
|
|
|
ngModuleRef.destroy();
|
|
|
|
expect(document.body.querySelector('app-root')).toBeFalsy(); // host element is removed
|
|
expect(document.body.childNodes.length).toEqual(2); // other elements are preserved
|
|
}),
|
|
);
|
|
|
|
// See https://github.com/angular/angular/issues/45108
|
|
it(
|
|
'should clear bootstrapped component contents when the animation engine is requested during initialization',
|
|
withBody('<div>before</div><app-root></app-root><div>after</div>', async () => {
|
|
@Injectable({providedIn: 'root'})
|
|
class AppService {
|
|
// The `RendererFactory2` is injected here explicitly because we import the
|
|
// `BrowserAnimationsModule`. The `RendererFactory2` will be provided with
|
|
// `AnimationRendererFactory` which relies on the `AnimationEngine`. We want to ensure
|
|
// that `ApplicationRef` is created earlier before the `AnimationEngine`, because
|
|
// previously the `AnimationEngine` was created before the `ApplicationRef` (see the
|
|
// link above to the original issue). The `ApplicationRef` was created after
|
|
// `APP_INITIALIZER` has been run but before the root module is bootstrapped.
|
|
constructor(rendererFactory: RendererFactory2) {}
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-root',
|
|
template: 'app-root content',
|
|
standalone: false,
|
|
})
|
|
class AppComponent {}
|
|
|
|
@NgModule({
|
|
imports: [BrowserAnimationsModule],
|
|
declarations: [AppComponent],
|
|
bootstrap: [AppComponent],
|
|
providers: [
|
|
// The `APP_INITIALIZER` token is requested before the root module is bootstrapped,
|
|
// the `useFactory` just injects the `AppService` that injects the
|
|
// `RendererFactory2`.
|
|
{
|
|
provide: APP_INITIALIZER,
|
|
useFactory: () => (appService: AppService) => appService,
|
|
deps: [AppService],
|
|
multi: true,
|
|
},
|
|
],
|
|
})
|
|
class AppModule {}
|
|
|
|
const ngModuleRef = await platformBrowser().bootstrapModule(AppModule);
|
|
|
|
const root = document.body.querySelector('app-root')!;
|
|
expect(root.textContent).toEqual('app-root content');
|
|
expect(document.body.childNodes.length).toEqual(3);
|
|
|
|
ngModuleRef.destroy();
|
|
|
|
expect(document.body.querySelector('app-root')).toBeFalsy(); // host element is removed
|
|
expect(document.body.childNodes.length).toEqual(2); // other elements are preserved
|
|
}),
|
|
);
|
|
|
|
// See https://github.com/angular/angular/issues/45108
|
|
it(
|
|
'should clear standalone bootstrapped component contents when the animation engine is requested during initialization',
|
|
withBody('<div>before</div><app-root></app-root><div>after</div>', async () => {
|
|
@Injectable({providedIn: 'root'})
|
|
class AppService {
|
|
// The `RendererFactory2` is injected here explicitly because we import the
|
|
// `BrowserAnimationsModule`. The `RendererFactory2` will be provided with
|
|
// `AnimationRendererFactory` which relies on the `AnimationEngine`. We want to ensure
|
|
// that `ApplicationRef` is created earlier before the `AnimationEngine`, because
|
|
// previously the `AnimationEngine` was created before the `ApplicationRef` (see the
|
|
// link above to the original issue). The `ApplicationRef` was created after
|
|
// `APP_INITIALIZER` has been run but before the root module is bootstrapped.
|
|
constructor(rendererFactory: RendererFactory2) {}
|
|
}
|
|
|
|
@Component({selector: 'app-root', template: 'app-root content', standalone: true})
|
|
class AppComponent {}
|
|
|
|
const appRef = await bootstrapApplication(AppComponent, {
|
|
providers: [
|
|
importProvidersFrom(BrowserAnimationsModule),
|
|
// The `APP_INITIALIZER` token is requested before the standalone component is
|
|
// bootstrapped, the `useFactory` just injects the `AppService` that injects the
|
|
// `RendererFactory2`.
|
|
{
|
|
provide: APP_INITIALIZER,
|
|
useFactory: () => (appService: AppService) => appService,
|
|
deps: [AppService],
|
|
multi: true,
|
|
},
|
|
],
|
|
});
|
|
|
|
const root = document.body.querySelector('app-root')!;
|
|
expect(root.textContent).toEqual('app-root content');
|
|
expect(document.body.childNodes.length).toEqual(3);
|
|
|
|
appRef.destroy();
|
|
|
|
expect(document.body.querySelector('app-root')).toBeFalsy(); // host element is removed
|
|
expect(document.body.childNodes.length).toEqual(2); // other elements are
|
|
}),
|
|
);
|
|
|
|
it(
|
|
'should clear bootstrapped component contents when async animations are used',
|
|
withBody('<div>before</div><app-root></app-root><div>after</div>', async () => {
|
|
@Component({selector: 'app-root', template: 'app-root content', standalone: true})
|
|
class AppComponent {}
|
|
|
|
const appRef = await bootstrapApplication(AppComponent, {
|
|
providers: [provideAnimationsAsync()],
|
|
});
|
|
|
|
const root = document.body.querySelector('app-root')!;
|
|
expect(root.textContent).toEqual('app-root content');
|
|
expect(document.body.childNodes.length).toEqual(3);
|
|
|
|
appRef.destroy();
|
|
|
|
expect(document.body.querySelector('app-root')).toBeFalsy(); // host element is removed
|
|
expect(document.body.childNodes.length).toEqual(2); // other elements are
|
|
}),
|
|
);
|
|
});
|
|
})();
|
|
|
|
@Injectable()
|
|
class MockAnimationEngine extends InjectableAnimationEngine {
|
|
captures: {[method: string]: any[]} = {};
|
|
triggers: AnimationTriggerMetadata[] = [];
|
|
|
|
private _capture(name: string, args: any[]) {
|
|
const data = (this.captures[name] = this.captures[name] || []);
|
|
data.push(args);
|
|
}
|
|
|
|
override registerTrigger(
|
|
componentId: string,
|
|
namespaceId: string,
|
|
hostElement: any,
|
|
name: string,
|
|
metadata: AnimationTriggerMetadata,
|
|
): void {
|
|
this.triggers.push(metadata);
|
|
}
|
|
|
|
override onInsert(namespaceId: string, element: any): void {
|
|
this._capture('onInsert', [element]);
|
|
}
|
|
|
|
override onRemove(namespaceId: string, element: any, domFn: () => any): void {
|
|
this._capture('onRemove', [element]);
|
|
}
|
|
|
|
override process(namespaceId: string, element: any, property: string, value: any): boolean {
|
|
this._capture('setProperty', [element, property, value]);
|
|
return true;
|
|
}
|
|
|
|
override listen(
|
|
namespaceId: string,
|
|
element: any,
|
|
eventName: string,
|
|
eventPhase: string,
|
|
callback: (event: any) => any,
|
|
): () => void {
|
|
// we don't capture the callback here since the renderer wraps it in a zone
|
|
this._capture('listen', [element, eventName, eventPhase]);
|
|
return () => {};
|
|
}
|
|
|
|
override flush() {}
|
|
|
|
override destroy(namespaceId: string) {}
|
|
}
|
|
|
|
@Injectable()
|
|
class ExtendedAnimationRendererFactory extends AnimationRendererFactory {
|
|
public log: string[] = [];
|
|
|
|
override begin() {
|
|
super.begin();
|
|
this.log.push('begin');
|
|
}
|
|
|
|
override end() {
|
|
super.end();
|
|
this.log.push('end');
|
|
}
|
|
}
|
|
|
|
function assertHasParent(element: any, yes: boolean = true) {
|
|
const parent = element.nativeElement.parentNode;
|
|
if (yes) {
|
|
expect(parent).toBeTruthy();
|
|
} else {
|
|
expect(parent).toBeFalsy();
|
|
}
|
|
}
|
|
|
|
function finishPlayers(players: AnimationPlayer[]) {
|
|
players.forEach((player) => player.finish());
|
|
}
|