Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,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.

## 21.2.25

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: ', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -80,3 +81,63 @@ describe('Toggle animation component', () => {
});
});
});

describe('Toggle animation component change detection', () => {
let animationEnd: Subject<void>;
let calls: string[];
let mockPlayer: any;
let mockCdr: { detectChanges: jasmine.Spy };

beforeEach(() => {
animationEnd = new Subject<void>();
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();
});
});
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,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';
Expand Down
30 changes: 30 additions & 0 deletions projects/igniteui-angular/tree/src/tree/tree.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading