diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c951577823..53614d10162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. 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 56453d47ffe..f77ad2fdb8a 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, 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'; @@ -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: ', () => { @@ -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(); @@ -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(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({ diff --git a/projects/igniteui-angular/core/src/services/overlay/overlay.ts b/projects/igniteui-angular/core/src/services/overlay/overlay.ts index 7c7945766cc..a510be1d24d 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) { - +