diff --git a/CHANGELOG.md b/CHANGELOG.md index f9844a6c481..7a2ba161316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ All notable changes for each version of this project will be documented in this - Pressing Enter in the grid UI, such as the filtering row, cell and row editors, the toolbar column hiding and pinning, Excel-style filtering and advanced filtering, as well as selecting a date in their date pickers with Enter, no longer submits a surrounding `form`. - `IgxAdvancedFilteringDialogComponent`, `IgxColumnActionsComponent`, `IgxPivotDataSelectorComponent` - Pressing Enter in their inputs no longer submits a surrounding `form` when they are rendered outside of the grid, e.g. used standalone, or the advanced filtering dialog opened with custom overlay settings. +- `IgxExpansionPanelComponent` + - When the collapse animation ends, the body is no longer shown at full height for a frame before it is removed, e.g. in zoneless applications. +- `IgxStepperComponent` + - When the animation of the previously active step ends, its content is no longer shown for a frame before it is removed, e.g. in zoneless applications. Previously it was shown at full height in vertical orientation, and in its original place behind the active step's content in horizontal orientation. +- `IgxTreeComponent` + - When the collapse animation of a node ends, its child nodes are no longer shown at full height for a frame before they are removed, e.g. in zoneless applications. ## 22.2.6 diff --git a/projects/igniteui-angular/carousel/src/carousel/carousel-base.ts b/projects/igniteui-angular/carousel/src/carousel/carousel-base.ts index e6538fca1ba..bc23d7698ca 100644 --- a/projects/igniteui-angular/carousel/src/carousel/carousel-base.ts +++ b/projects/igniteui-angular/carousel/src/carousel/carousel-base.ts @@ -177,14 +177,17 @@ export abstract class IgxCarouselComponentBase implements OnDestroy { this.leaveAnimationPlayer = this.animationService.buildAnimation(animation, this.getPreviousElement()); this.leaveAnimationPlayer.animationEnd.subscribe(() => { + this.animationPosition = 0; + this.newDuration = 0; + this.leaveAnimationDone.emit(); + // Render the left item's state synchronously, so that e.g. its content is removed before destroying + // the player clears the animation styles; otherwise it is painted in place for a frame. + this.cdr.detectChanges(); // TODO: animation may never end. Find better way to clean up the player if (this.leaveAnimationPlayer) { this.leaveAnimationPlayer.destroy(); this.leaveAnimationPlayer = null!; } - this.animationPosition = 0; - this.newDuration = 0; - this.leaveAnimationDone.emit(); }); this.leaveAnimationPlayer.play(); } diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.spec.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.spec.ts index 8ee1d898091..b391ac5d3ac 100644 --- a/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.spec.ts +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/expansion-panel.spec.ts @@ -1081,6 +1081,40 @@ describe('igxExpansionPanel', () => { expect(panelElement.lastElementChild.getAttribute('aria-labelledby')).toEqual(title.id); expect(panelElement.lastElementChild.getAttribute('aria-label')).toEqual(`${panel.id}-region`); })); + + it('Should remove the body before the close animation player is reset', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxExpansionPanelSampleComponent); + fixture.detectChanges(); + const panel = fixture.componentInstance.panel; + const panelElement: HTMLElement = panel.nativeElement; + + panel.expand(); + tick(1000); + fixture.detectChanges(); + expect(panelElement.querySelector('igx-expansion-panel-body')).not.toBeNull(); + + // capture whether the body is still rendered at the moment the close player resets the + // element styles; if it is, the body is painted at full height for a frame + let bodyRenderedOnReset: boolean | undefined; + const animationService = (panel as any).animationService; + const buildAnimation = animationService.buildAnimation.bind(animationService); + spyOn(animationService, 'buildAnimation').and.callFake((...args: any[]) => { + const player = buildAnimation(...args); + const reset = player.reset.bind(player); + spyOn(player, 'reset').and.callFake(() => { + bodyRenderedOnReset = !!panelElement.querySelector('igx-expansion-panel-body'); + reset(); + }); + return player; + }); + + panel.collapse(); + tick(1000); + + expect(panel.collapsed).toBeTruthy(); + expect(bodyRenderedOnReset).toBe(false); + expect(panelElement.querySelector('igx-expansion-panel-body')).toBeNull(); + })); }); describe('Rendering tests: ', () => { diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/toggle-animation-component.spec.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/toggle-animation-component.spec.ts index f4905be4d77..e480db1c7d6 100644 --- a/projects/igniteui-angular/expansion-panel/src/expansion-panel/toggle-animation-component.spec.ts +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/toggle-animation-component.spec.ts @@ -1,6 +1,7 @@ +import { ChangeDetectorRef, ElementRef } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { noop } from 'rxjs'; +import { noop, Subject } from 'rxjs'; import { IgxAngularAnimationService } from 'igniteui-angular/core'; import { ANIMATION_TYPE, ToggleAnimationPlayer } from './toggle-animation-component'; import { growVerIn, growVerOut } from 'igniteui-angular/animations'; @@ -80,3 +81,63 @@ describe('Toggle animation component', () => { }); }); }); + +describe('Toggle animation component change detection', () => { + let animationEnd: Subject; + let calls: string[]; + let mockPlayer: any; + let mockCdr: { detectChanges: jasmine.Spy }; + + beforeEach(() => { + animationEnd = new Subject(); + calls = []; + mockPlayer = { + animationEnd, + position: 0, + init: jasmine.createSpy('init'), + play: jasmine.createSpy('play'), + hasStarted: jasmine.createSpy('hasStarted').and.returnValue(false), + reset: jasmine.createSpy('reset').and.callFake(() => calls.push('reset')), + destroy: jasmine.createSpy('destroy') + }; + mockCdr = { detectChanges: jasmine.createSpy('detectChanges').and.callFake(() => calls.push('detectChanges')) }; + TestBed.configureTestingModule({ + providers: [ + { provide: IgxAngularAnimationService, useValue: { buildAnimation: () => mockPlayer } }, + { provide: ChangeDetectorRef, useValue: mockCdr }, + MockTogglePlayer + ] + }); + }); + + it('Should render the owner after the close callback and before the close player is reset', () => { + const player = TestBed.inject(MockTogglePlayer); + player.closeAnimationDone.subscribe(() => calls.push('closeAnimationDone')); + + player.playCloseAnimation({ nativeElement: {} } as ElementRef, () => calls.push('onDone')); + expect(mockCdr.detectChanges).not.toHaveBeenCalled(); + animationEnd.next(); + + expect(calls).toEqual(['onDone', 'closeAnimationDone', 'detectChanges', 'reset']); + }); + + it('Should not render the owner when the open animation is done', () => { + const player = TestBed.inject(MockTogglePlayer); + + player.playOpenAnimation({ nativeElement: {} } as ElementRef); + animationEnd.next(); + + expect(mockPlayer.reset).toHaveBeenCalled(); + expect(mockCdr.detectChanges).not.toHaveBeenCalled(); + }); + + it('Should not render the owner when the close animation is done after it is destroyed', () => { + const player = TestBed.inject(MockTogglePlayer); + + player.playCloseAnimation({ nativeElement: {} } as ElementRef); + player.ngOnDestroy(); + animationEnd.next(); + + expect(mockCdr.detectChanges).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/igniteui-angular/expansion-panel/src/expansion-panel/toggle-animation-component.ts b/projects/igniteui-angular/expansion-panel/src/expansion-panel/toggle-animation-component.ts index 42799d29269..dd30560dc80 100644 --- a/projects/igniteui-angular/expansion-panel/src/expansion-panel/toggle-animation-component.ts +++ b/projects/igniteui-angular/expansion-panel/src/expansion-panel/toggle-animation-component.ts @@ -1,5 +1,5 @@ import { AnimationReferenceMetadata } from '@angular/animations'; -import { Directive, ElementRef, EventEmitter, inject, OnDestroy } from '@angular/core'; +import { ChangeDetectorRef, Directive, ElementRef, EventEmitter, inject, OnDestroy } from '@angular/core'; import { noop, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @@ -65,6 +65,12 @@ export abstract class ToggleAnimationPlayer implements ToggleAnimationOwner, OnD closeAnimation: growVerOut }; + /** + * Renders the owner synchronously when its close animation is done. Optional, as the player can be + * created outside of a view, e.g. as a provider. + */ + private readonly _toggleAnimationCdr = inject(ChangeDetectorRef, { optional: true }); + private closeInterrupted = false; private openInterrupted = false; @@ -151,6 +157,11 @@ export abstract class ToggleAnimationPlayer implements ToggleAnimationOwner, OnD if (!(type === ANIMATION_TYPE.OPEN ? this.openInterrupted : this.closeInterrupted)) { targetEmitter.emit(); } + if (type === ANIMATION_TYPE.CLOSE) { + // Render the collapsed state synchronously, so that the content is removed before resetting the player + // clears the animation styles; otherwise (e.g. zoneless) it is painted at full height for a frame. + this._toggleAnimationCdr?.detectChanges(); + } this.cleanUpPlayer(type); } diff --git a/projects/igniteui-angular/stepper/src/stepper/stepper.component.spec.ts b/projects/igniteui-angular/stepper/src/stepper/stepper.component.spec.ts index fa4d2ac5f7e..723d18bbbaa 100644 --- a/projects/igniteui-angular/stepper/src/stepper/stepper.component.spec.ts +++ b/projects/igniteui-angular/stepper/src/stepper/stepper.component.spec.ts @@ -716,6 +716,80 @@ describe('Rendering Tests', () => { expect((stepper as any).stepperService.visitedSteps).toContain(stepper.steps[firstAccessibleStepIdx]); })); + it('should remove the step content before the vertical collapse animation player is reset', fakeAsync(() => { + stepper.orientation = IgxStepperOrientation.Vertical; + stepper.steps[0].active = true; + fix.detectChanges(); + tick(1000); + const previousActiveStep = stepper.steps[0]; + const getContent = () => previousActiveStep.nativeElement.querySelector('[igxStepContent]'); + expect(getContent()).not.toBeNull(); + + // capture whether the content is still rendered at the moment the collapse player resets the + // container styles; if it is, the content is painted at full height for a frame + let contentRenderedOnReset: boolean | undefined; + const animationService = (previousActiveStep as any).animationService; + const buildAnimation = animationService.buildAnimation.bind(animationService); + spyOn(animationService, 'buildAnimation').and.callFake((...args: any[]) => { + const player = buildAnimation(...args); + if (args[1] === previousActiveStep.contentContainer.nativeElement) { + const reset = player.reset.bind(player); + spyOn(player, 'reset').and.callFake(() => { + contentRenderedOnReset = !!getContent(); + reset(); + }); + } + return player; + }); + const activeChangeSpy = spyOn(previousActiveStep.activeChange, 'emit').and.callThrough(); + + stepper.next(); + fix.detectChanges(); + tick(1000); + + expect(activeChangeSpy).toHaveBeenCalledWith(false); + expect(contentRenderedOnReset).toBe(false); + expect(getContent()).toBeNull(); + })); + + it('should remove the step content before the horizontal leave animation player is destroyed', fakeAsync(() => { + stepper.orientation = IgxStepperOrientation.Horizontal; + stepper.steps[0].active = true; + fix.detectChanges(); + tick(1000); + const previousActiveStep = stepper.steps[0]; + // in horizontal orientation the content is rendered in the stepper body, not in the step element + const contentContainer: HTMLElement = previousActiveStep.contentContainer.nativeElement; + const getContent = () => contentContainer.querySelector('[igxStepContent]'); + expect(getContent()).not.toBeNull(); + + // capture whether the content is still rendered at the moment the leave player is destroyed, which + // clears its styles; if it is, the content is painted in place behind the active step for a frame + let contentRenderedOnDestroy: boolean | undefined; + const animationService = (stepper as any).animationService; + const buildAnimation = animationService.buildAnimation.bind(animationService); + spyOn(animationService, 'buildAnimation').and.callFake((...args: any[]) => { + const player = buildAnimation(...args); + if (args[1] === contentContainer) { + const destroy = player.destroy.bind(player); + spyOn(player, 'destroy').and.callFake(() => { + contentRenderedOnDestroy ??= !!getContent(); + destroy(); + }); + } + return player; + }); + const activeChangeSpy = spyOn(previousActiveStep.activeChange, 'emit').and.callThrough(); + + stepper.next(); + fix.detectChanges(); + tick(1000); + + expect(activeChangeSpy).toHaveBeenCalledWith(false); + expect(contentRenderedOnDestroy).toBe(false); + expect(getContent()).toBeNull(); + })); + it('should properly collapse the previously active step in horizontal orientation and animation type \'fade\'', fakeAsync(() => { stepper.orientation = IgxStepperOrientation.Horizontal; stepper.horizontalAnimationType = 'fade'; diff --git a/projects/igniteui-angular/tree/src/tree/tree.spec.ts b/projects/igniteui-angular/tree/src/tree/tree.spec.ts index f520928cbce..f55cfe4503f 100644 --- a/projects/igniteui-angular/tree/src/tree/tree.spec.ts +++ b/projects/igniteui-angular/tree/src/tree/tree.spec.ts @@ -564,6 +564,36 @@ describe('IgxTree #treeView', () => { pending('Test not implemented'); }); + it('Should remove the node children before the collapse animation player is reset', fakeAsync(() => { + const node = tree.rootNodes[0]; + node.expanded = true; + fix.detectChanges(); + const getChildrenGroup = () => node.nativeElement.querySelector(':scope > .igx-tree-node__group'); + expect(getChildrenGroup()).not.toBeNull(); + + // capture whether the children are still rendered at the moment the collapse player resets the + // element styles; if they are, they are painted at full height for a frame + let childrenRenderedOnReset: boolean | undefined; + const animationService = (node as any).animationService; + const buildAnimation = animationService.buildAnimation.bind(animationService); + spyOn(animationService, 'buildAnimation').and.callFake((...args: any[]) => { + const player = buildAnimation(...args); + const reset = player.reset.bind(player); + spyOn(player, 'reset').and.callFake(() => { + childrenRenderedOnReset = !!getChildrenGroup(); + reset(); + }); + return player; + }); + + node.collapse(); + tick(1000); + + expect(node.expanded).toBe(false); + expect(childrenRenderedOnReset).toBe(false); + expect(getChildrenGroup()).toBeNull(); + })); + it('Should do nothing when calling expand()/collapse() on expanded/collapsed node', fakeAsync(() => { const expandingSpy = spyOn(tree.nodeExpanding, 'emit').and.callThrough(); const collapsingSpy = spyOn(tree.nodeCollapsing, 'emit').and.callThrough();