Skip to content
Open
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,13 @@ All notable changes for each version of this project will be documented in this
- Touch pans no longer leave slides partially moved or visible behind the current slide, for example when a pan is cancelled, reversed past the first or last slide of a non-looping carousel, released as a tap, or interrupted by removing slides or changing `gesturesSupport` or `vertical`.
- Panning a carousel without slides no longer throws an error, and panning a carousel with a single slide no longer moves the slide against itself.
- A slide activated through its `active` property now animates in the direction of its position, or in its own `direction` when one is set.
- `IgxDatePickerComponent`
- Opening the calendar from the default icon no longer briefly selects the input value in the `inputFormat` (e.g. `09/15/2026`). As with `igx-picker-toggle`, the icon click no longer propagates to the picker's ancestors.
- The calendar receives the focus as soon as it starts opening instead of after the opening animation.
- `IgxNavigationToggleDirective`, `IgxNavigationCloseDirective`
- The `igxNavToggle` and `igxNavClose` inputs are now public, so they can be set in applications compiled with `strictInputAccessModifiers`. Previously the inputs were private and setting them failed the template type check.
- `IgxOverlayService`
- Detaching an overlay with a dynamically created component, such as the date picker calendar, no longer leaves the component visible in its original place until Angular removes it.
- `IgxStepperComponent`
- When the steps are rendered with `@for`, no `ExpressionChangedAfterItHasBeenCheckedError` is thrown on init in development mode, and the active step and the step indicators are rendered on init in `OnPush` hosts.
- `activeChange` is no longer emitted twice for the step activated on init, or for steps bound to `[active]="false"` that were not active. It is now emitted for the steps whose collapse animation is interrupted by changing `orientation`, `verticalAnimationType` or `horizontalAnimationType`.
Expand Down
146 changes: 143 additions & 3 deletions projects/igniteui-angular/core/src/services/overlay/overlay.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Component, ComponentRef, ElementRef, HostBinding, inject, Injector, OnDestroy, ViewChild, ViewContainerRef, ViewEncapsulation, ChangeDetectionStrategy } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { NoopAnimationsModule, provideAnimations } from '@angular/platform-browser/animations';
import { PlatformUtil } from 'igniteui-angular';
import { scaleInVerTop, scaleOutVerTop } from 'igniteui-angular/animations';
import { fadeIn, fadeOut, scaleInVerTop, scaleOutVerTop } from 'igniteui-angular/animations';
import { IgxAvatarComponent } from 'igniteui-angular/avatar';
import { IgxCalendarComponent } from 'igniteui-angular/calendar';
import { IgxCalendarContainerComponent } from 'igniteui-angular/date-picker';
import { IgxToggleDirective } from 'igniteui-angular/directives';
import { first } from 'rxjs/operators';
import { firstValueFrom } from 'rxjs';
import { filter, first } from 'rxjs/operators';
import { UIInteractions, wait } from '../../../../test-utils/ui-interactions.spec';
import { IgxAngularAnimationService } from '../animation/angular-animation-service';
import { IgxOverlayService } from './overlay';
Expand Down Expand Up @@ -520,6 +521,40 @@ describe('igxOverlay', () => {
overlay.detach(id2);
parent.remove();
});

it('Should restore a caller-owned element with its own styles on detach', () => {
const parent = document.createElement('div');
document.body.appendChild(parent);
const element = document.createElement('div');
element.style.display = 'flex';
element.style.height = '20px';
parent.appendChild(element);
const elementRef = new ElementRef(element);
const settings: OverlaySettings = {
modal: false,
positionStrategy: new GlobalPositionStrategy({ openAnimation: null, closeAnimation: null })
};

let id = overlay.attach(elementRef, settings);
overlay.show(id);
overlay.hide(id);
overlay.detach(id);

expect(element.parentElement).toBe(parent);
expect(element.style.display).toEqual('flex');
expect(element.getBoundingClientRect().height).toEqual(20);

// the restored element can be shown again
id = overlay.attach(elementRef, settings);
overlay.show(id);
expect(overlay.getOverlayById(id).visible).toBe(true);
expect(element.closest(`.${CLASS_OVERLAY_WRAPPER}`)).not.toBeNull();

overlay.detach(id);
expect(element.parentElement).toBe(parent);
expect(element.style.display).toEqual('flex');
parent.remove();
});
});

describe('Unit Tests: ', () => {
Expand Down Expand Up @@ -590,6 +625,34 @@ describe('igxOverlay', () => {
overlay.detachAll();
});

it('Should not expose the host of a destroyed dynamic component on detach', async () => {
const fixture = TestBed.createComponent(EmptyPageComponent);
fixture.detectChanges();
const overlay = fixture.componentInstance.overlay;
const variants: { name: string, settings: OverlaySettings }[] = [
{ name: 'in-place', settings: { modal: false } },
{ name: 'outlet', settings: { modal: false, outlet: fixture.componentInstance.divElement } },
{ name: 'container', settings: { modal: false, positionStrategy: new ContainerPositionStrategy() } }
];

for (const { name, settings } of variants) {
const id = overlay.attach(SimpleDynamicComponent, fixture.componentInstance.viewContainerRef, settings);
const host: HTMLElement = overlay.getOverlayById(id).componentRef.location.nativeElement;
overlay.show(id);
await nextFrame();
overlay.hide(id);
await fixture.whenStable();

overlay.detach(id);
// the host may stay in the DOM until Angular removes the destroyed view
expect(host.isConnected && host.checkVisibility(), name).toBe(false);
expect(host.getBoundingClientRect().height, name).toEqual(0);

fixture.detectChanges();
expect(host.isConnected, name).toBe(false);
}
});

it('Should show component passed to overlay.', async () => {
const fixture = TestBed.createComponent(EmptyPageComponent);
fixture.detectChanges();
Expand Down Expand Up @@ -4846,6 +4909,83 @@ describe('igxOverlay', () => {
overlay.detachAll();
});
});

describe('Integration tests - dynamic component teardown with animations: ', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
providers: [provideAnimations()]
}).compileComponents();
});

it('Should not render a dynamic component host outside its wrapper while detaching it', async () => {
const fixture = TestBed.createComponent(EmptyPageComponent);
fixture.detectChanges();
const overlay = fixture.componentInstance.overlay;
const hostParent = (fixture.nativeElement as HTMLElement).parentElement;
const initialChildCount = hostParent.childElementCount;

const id = overlay.attach(SimpleDynamicComponent, fixture.componentInstance.viewContainerRef, {
modal: false,
closeOnOutsideClick: false,
positionStrategy: new GlobalPositionStrategy({ openAnimation: fadeIn, closeAnimation: fadeOut })
});
const componentRef = overlay.getOverlayById(id).componentRef;
const host: HTMLElement = componentRef.location.nativeElement;
const destroySpy = vi.spyOn(componentRef, 'destroy');

const opened = firstValueFrom(overlay.opened.pipe(filter(e => e.id === id)));
overlay.show(id);
await opened;
expect(host.closest(`.${CLASS_OVERLAY_WRAPPER}`)).not.toBeNull();

// once unwrapped, the host must neither render nor take layout space
const exposedStates: string[] = [];
const recordState = (moment: string) => {
if (host.isConnected && !host.closest(`.${CLASS_OVERLAY_WRAPPER}`)) {
const { width, height } = host.getBoundingClientRect();
if (host.checkVisibility({ opacityProperty: true, visibilityProperty: true }) || width || height) {
exposedStates.push(`${moment}: rendered at ${width}x${height}`);
}
}
};

let closedCount = 0;
const detached = new Promise<void>(resolve => {
overlay.closed.pipe(filter(e => e.id === id)).subscribe(() => {
closedCount++;
// pickers detach their dynamic content as soon as it is closed
overlay.detach(id);
recordState('after detach');
resolve();
});
});
let sampling = true;
const sampleFrame = () => {
recordState('animation frame');
if (sampling) {
requestAnimationFrame(sampleFrame);
}
};
requestAnimationFrame(sampleFrame);

overlay.hide(id);
await detached;
for (let frame = 0; frame < 3; frame++) {
await new Promise(resolve => requestAnimationFrame(resolve));
}
sampling = false;
fixture.detectChanges();

expect(exposedStates).toEqual([]);
expect(closedCount).toEqual(1);
expect(destroySpy).toHaveBeenCalledTimes(1);
expect(componentRef.hostView.destroyed).toBe(true);
expect(host.isConnected).toBe(false);
expect(overlay.getOverlayById(id)).toBeUndefined();
expect(document.getElementsByClassName(CLASS_OVERLAY_WRAPPER).length).toEqual(0);
expect(hostParent.childElementCount).toEqual(initialChildCount);
});
});
});

@Component({
Expand Down
10 changes: 10 additions & 0 deletions projects/igniteui-angular/core/src/services/overlay/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,13 +790,23 @@ export class IgxOverlayService implements OnDestroy {
}

private cleanUp(info: OverlayInfo) {
this.hideDynamicComponent(info);
this.removeWrapper(info);
this.restoreHook(info);
this.destroyComponent(info);
this.unregisterOverlay(info);
this.releaseResources(info);
}

/**
* Hides a dynamically created component before unwrapping it, as Angular may remove its host after `destroyComponent`.
*/
private hideDynamicComponent(info: OverlayInfo) {
if (info.componentRef) {
info.elementRef!.nativeElement.style.display = 'none';
}
}

/**
* Reverses the wrapper insertion performed by `insertWrapper`, restoring the element to its original DOM position.
*/
Expand Down
11 changes: 11 additions & 0 deletions projects/igniteui-angular/date-picker/src/date-picker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ Custom formats for the input field.
```
If the `inputFormat` is not set, it will default to the format used by the browser. The `displayFormat` accepts all supported formats by Angular's `DatePipe`.

While editing, the day and month always have two digits (e.g. `09/05/2026`), whereas the default `displayFormat` may omit leading zeros (e.g. `9/5/2026`). To show the same value in both states, set a padded format such as `MM/dd/yyyy` to both `inputFormat` and `displayFormat`.

To use a custom toggle icon, project it in an `igx-picker-toggle`. Unlike other `igx-prefix`/`igx-suffix` content, clicking it does not focus the input:
```html
<igx-date-picker>
<igx-picker-toggle igxSuffix>
<igx-icon>alarm</igx-icon>
</igx-picker-toggle>
</igx-date-picker>
```


DatePicker with cancel and today buttons
```html
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<igx-input-group [type]="type">
@if (!toggleComponents.length) {
<igx-prefix (click)="toggle()">
<igx-prefix (click)="onToggleClick($event)">
<igx-icon
[title]="
value
Expand Down
Loading
Loading