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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
All notable changes for each version of this project will be documented in this file.


## 22.2.4

### Bug Fixes

- `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.
- `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.

## 22.2.3

### Bug Fixes
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, ViewChild, ViewContainerRef, ViewEncapsulation, ChangeDetectionStrategy } from '@angular/core';
import { fakeAsync, TestBed, tick, waitForAsync } 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 @@ -511,6 +512,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).toBeTrue();
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 @@ -581,6 +616,34 @@ describe('igxOverlay', () => {
overlay.detachAll();
}));

it('Should not expose the host of a destroyed dynamic component on detach', fakeAsync(() => {
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);
tick();
overlay.hide(id);
tick();

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

fixture.detectChanges();
expect(host.isConnected).withContext(name).toBeFalse();
}
}));

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

describe('Integration tests - dynamic component teardown with animations: ', () => {
beforeEach(waitForAsync(() => {
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 = spyOn(componentRef, 'destroy').and.callThrough();

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).toBeTrue();
expect(host.isConnected).toBeFalse();
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