From 93ba750a973dcaa1710495df7a78f06b7369b37d Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Tue, 13 Oct 2026 18:40:13 +0300 Subject: [PATCH 1/7] fix(overlay): hide dynamic content host while detaching it Detaching an overlay moves the host of a dynamically created component back to its original position before destroying the component. Angular may remove the destroyed host later (the animations renderer defers it until the next change detection), so the browser could paint the host outside its hidden wrapper for a frame. With zoneless change detection this made the date and date range picker calendars flash below the input and shift the page layout after a selection. Hide the host of a dynamically created component before unwrapping it. Caller-owned ElementRef content is restored unchanged. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + .../core/src/services/overlay/overlay.spec.ts | 146 +++++++++++++++++- .../core/src/services/overlay/overlay.ts | 13 ++ .../date-picker/date-picker.component.spec.ts | 34 ++++ 4 files changed, 192 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8a74574ba3..a69ba2ce030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ All notable changes for each version of this project will be documented in this - **Forms** - `igx-checkbox`, `igx-switch` and `igx-radio-group` no longer turn invalid while the bound Signal Forms field is untouched and pristine, e.g. right after `reset()` or when a cross-field rule makes a pristine field required. The invalid state now shows only once the field is touched or dirty, as with reactive and template-driven forms. +- `IgxOverlayService` + - Detaching an overlay that shows a dynamically created component, such as the calendar of the date and date range pickers, no longer renders the component in its original place in the page for a frame before it is removed. This caused the calendar to flash below the input after a date was selected. - `IgxTabsComponent` - The selected tab is now rendered correctly in zoneless applications, including the initially selected tab, selection changes made from code, and selection changes after tabs are added or removed. 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..c441b74693b 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 out of its wrapper, the host must neither render nor take part in the page layout + 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..70de53c8b2a 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,18 @@ export class IgxOverlayService implements OnDestroy { this.releaseResources(info); } + /** + * Hides the host of a dynamically created component before it leaves the hidden wrapper. The component + * is destroyed right after, but Angular may remove its host from the DOM later (e.g. with animations enabled), + * so a restored host would otherwise render and take part in the page layout until then. + * Caller-owned elements (`ElementRef`) are restored as they are. + */ + 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/date-picker.component.spec.ts b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts index 766d7132be3..5e51a7c894f 100644 --- a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts @@ -771,6 +771,40 @@ describe('IgxDatePicker', () => { .withContext('focus should return to the picker input') .toBeTrue(); })); + + it('should not render the calendar in the page layout while the closed dropdown is torn down', fakeAsync(() => { + fixture.componentInstance.date = new Date(2026, 8, 15); + fixture.detectChanges(); + datePicker.open(); + tick(); + fixture.detectChanges(); + + const calendarContainer: HTMLElement = fixture.debugElement + .query(By.directive(IgxCalendarContainerComponent)).nativeElement; + const teardownStates = []; + // subscribed after the picker, so it runs right after the picker detaches the closed overlay + const subscription = TestBed.inject(IgxOverlayService).closed.subscribe(() => { + const { width, height } = calendarContainer.getBoundingClientRect(); + teardownStates.push({ + wrapped: !!calendarContainer.closest('.igx-overlay__wrapper'), + rendered: calendarContainer.checkVisibility({ opacityProperty: true, visibilityProperty: true }), + width, + height + }); + }); + + const selectedDate = new Date(2026, 8, 20); + fixture.debugElement.query(By.css(`#${CSS.escape(selectedDate.getTime().toString())}`)).nativeElement.click(); + tick(); + subscription.unsubscribe(); + + expect(datePicker.value).toEqual(selectedDate); + expect(datePicker.collapsed).toBeTrue(); + expect(teardownStates).toEqual([{ wrapped: false, rendered: false, width: 0, height: 0 }]); + + fixture.detectChanges(); + expect(calendarContainer.isConnected).toBeFalse(); + })); }); describe('Input and Display formats', () => { From cc378b062f1be732a63856fe791aca6d95f9300e Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Mon, 12 Oct 2026 14:48:50 +0300 Subject: [PATCH 2/7] fix(date-picker): do not focus input on default toggle click The click on the default calendar icon bubbled to the input group, which focused the input while the calendar opened. The date-time editor then switched the value to the input format and selected it until the calendar received focus, e.g. 9/15/2026 -> 09/15/2026 -> 9/15/2026. With animations disabled, the input even kept the focus instead of the calendar. Stop the click propagation like the projected igx-picker-toggle does and document how to show the same padded value while editing and displaying. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + .../date-picker/src/date-picker/README.md | 8 +++ .../date-picker/date-picker.component.html | 2 +- .../date-picker/date-picker.component.spec.ts | 59 +++++++++++++++++++ .../src/date-picker/date-picker.component.ts | 7 +++ 5 files changed, 77 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a69ba2ce030..f4359cbd947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ All notable changes for each version of this project will be documented in this - **Forms** - `igx-checkbox`, `igx-switch` and `igx-radio-group` no longer turn invalid while the bound Signal Forms field is untouched and pristine, e.g. right after `reset()` or when a cross-field rule makes a pristine field required. The invalid state now shows only once the field is touched or dirty, as with reactive and template-driven forms. +- `IgxDatePickerComponent` + - Clicking the default calendar icon no longer focuses the input while the calendar opens, so its value no longer briefly switches to the `inputFormat` (e.g. `09/15/2026` instead of `9/15/2026`) and gets selected. As with a projected `igx-picker-toggle`, the click on the icon no longer propagates to the picker's ancestors. - `IgxOverlayService` - Detaching an overlay that shows a dynamically created component, such as the calendar of the date and date range pickers, no longer renders the component in its original place in the page for a frame before it is removed. This caused the calendar to flash below the input after a date was selected. - `IgxTabsComponent` 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..a849fc5d195 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,14 @@ 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 the input is focused, the date is edited in the `inputFormat`, which by default uses two-digit day and month parts (e.g. `09/05/2026` for `en-US`). Otherwise the value is shown in the `displayFormat`, which by default uses the locale's numeric format and may omit leading zeros (e.g. `9/5/2026`). To show the same padded value in both states, set the same numeric format to both: +```html + + +``` + +Clicking the default calendar icon opens the calendar and moves the focus to it without focusing the input. Once a date is selected or the calendar is closed with Escape, the focus returns to the input. + 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) { - + - -``` - -Clicking the default calendar icon opens the calendar and moves the focus to it without focusing the input. Once a date is selected or the calendar is closed with Escape, the focus returns to the input. +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 icon for toggling the calendar, project it in an `igx-picker-toggle`. It replaces the default icon and does not focus the input either: +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 @@ -69,7 +63,6 @@ To use a custom icon for toggling the calendar, project it in an `igx-picker-tog ``` -Clicking other projected `igx-prefix` or `igx-suffix` content focuses the input. A click handler that opens the calendar from such content should stop the event propagation, so the input does not receive focus and switch to the `inputFormat` while the calendar opens. DatePicker with cancel and today buttons diff --git a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts index 959b1e97787..d55447105a1 100644 --- a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.spec.ts @@ -841,7 +841,7 @@ describe('IgxDatePicker', () => { const calendarContainer: HTMLElement = fixture.debugElement .query(By.directive(IgxCalendarContainerComponent)).nativeElement; const teardownStates = []; - // subscribed after the picker, so it runs right after the picker detaches the closed overlay + // runs right after the picker detaches the closed overlay const subscription = TestBed.inject(IgxOverlayService).closed.subscribe(() => { const { width, height } = calendarContainer.getBoundingClientRect(); teardownStates.push({ diff --git a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts index f02239a846d..2550d51c9f8 100644 --- a/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts +++ b/projects/igniteui-angular/date-picker/src/date-picker/date-picker.component.ts @@ -586,7 +586,7 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr .attach(IgxCalendarContainerComponent, this.viewContainerRef, overlaySettings); this._overlayService.show(this._overlayId); if (!this.collapsed) { - // focus the calendar while it opens, so a focused input does not stay in edit mode until then + // do not leave a focused input in edit mode during the opening animation this._calendar?.wrapper?.nativeElement?.focus(); } } @@ -815,7 +815,7 @@ export class IgxDatePickerComponent extends PickerBaseDirective implements Contr /** @hidden @internal */ public onToggleClick(event: MouseEvent): void { - // do not focus the input on click, the calendar receives focus once opened + // do not focus input on click event.stopPropagation(); this.toggle(); } From c23d41249ec96c1125a78347707a51831da2c2ce Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Thu, 22 Oct 2026 11:55:31 +0300 Subject: [PATCH 7/7] docs(date-picker): move changelog entries to 22.2.4 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9554a4e4c31..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 @@ -26,11 +36,6 @@ All notable changes for each version of this project will be documented in this - **Forms** - `igx-checkbox`, `igx-switch` and `igx-radio-group` no longer turn invalid while the bound Signal Forms field is untouched and pristine, e.g. right after `reset()` or when a cross-field rule makes a pristine field required. The invalid state now shows only once the field is touched or dirty, as with reactive and template-driven forms. -- `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. - `IgxTabsComponent` - The selected tab is now rendered correctly in zoneless applications, including the initially selected tab, selection changes made from code, and selection changes after tabs are added or removed.