diff --git a/CHANGELOG.md b/CHANGELOG.md index cadad248587..c67ac21cb5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/projects/igniteui-angular/core/src/services/overlay/overlay.spec.ts b/projects/igniteui-angular/core/src/services/overlay/overlay.spec.ts index 30dfb1eed2c..d7e0f1d037c 100644 --- a/projects/igniteui-angular/core/src/services/overlay/overlay.spec.ts +++ b/projects/igniteui-angular/core/src/services/overlay/overlay.spec.ts @@ -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'; @@ -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: ', () => { @@ -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(); @@ -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(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({ diff --git a/projects/igniteui-angular/core/src/services/overlay/overlay.ts b/projects/igniteui-angular/core/src/services/overlay/overlay.ts index 0c982bdeb5d..01ce55e3acc 100644 --- a/projects/igniteui-angular/core/src/services/overlay/overlay.ts +++ b/projects/igniteui-angular/core/src/services/overlay/overlay.ts @@ -790,6 +790,7 @@ export class IgxOverlayService implements OnDestroy { } private cleanUp(info: OverlayInfo) { + this.hideDynamicComponent(info); this.removeWrapper(info); this.restoreHook(info); this.destroyComponent(info); @@ -797,6 +798,15 @@ export class IgxOverlayService implements OnDestroy { 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. */ diff --git a/projects/igniteui-angular/date-picker/src/date-picker/README.md b/projects/igniteui-angular/date-picker/src/date-picker/README.md index 3034888612d..07072ffefa7 100644 --- a/projects/igniteui-angular/date-picker/src/date-picker/README.md +++ b/projects/igniteui-angular/date-picker/src/date-picker/README.md @@ -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 + + + alarm + + +``` + DatePicker with cancel and today buttons ```html diff --git a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.html b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.html index d011651e47c..e561998c868 100644 --- a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.html +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.html @@ -1,6 +1,6 @@ @if (!toggleComponents.length) { - +