diff --git a/projects/igniteui-angular/core/src/services/overlay/overlay.ts b/projects/igniteui-angular/core/src/services/overlay/overlay.ts index 687d0aa143e..ee7bc91c916 100644 --- a/projects/igniteui-angular/core/src/services/overlay/overlay.ts +++ b/projects/igniteui-angular/core/src/services/overlay/overlay.ts @@ -5,7 +5,7 @@ import { filter, takeUntil } from 'rxjs/operators'; import { fadeIn, fadeOut, IAnimationParams, scaleInHorLeft, scaleInHorRight, scaleInVerBottom, scaleInVerTop, scaleOutHorLeft, scaleOutHorRight, scaleOutVerBottom, scaleOutVerTop, slideInBottom, slideInTop, slideOutBottom, slideOutTop } from 'igniteui-angular/animations'; import { PlatformUtil } from '../../core/utils'; -import { IgxOverlayOutletDirective } from './utilities'; +import { IgxOverlayOutletDirective, OverlaySizeRegistry } from './utilities'; import { IgxAngularAnimationService } from '../animation/angular-animation-service'; import { AnimationService } from '../animation/animation'; import { AutoPositionStrategy } from './position/auto-position-strategy'; @@ -44,6 +44,7 @@ export class IgxOverlayService implements OnDestroy { private _zone = inject(NgZone); protected platformUtil = inject(PlatformUtil); private animationService = inject(IgxAngularAnimationService); + private sizeRegistry = inject(OverlaySizeRegistry); /** * Emitted just before the overlay content starts to open. @@ -331,11 +332,12 @@ export class IgxOverlayService implements OnDestroy { info.settings = eventArgs.settings; this._overlayInfos.push(info); info.hook = this.placeElementHook(info.elementRef.nativeElement); - const elementRect = info.elementRef.nativeElement.getBoundingClientRect(); - info.initialSize = { width: elementRect.width, height: elementRect.height }; // Get the size before moving the container into the overlay so that it does not forget about inherited styles. this.getComponentSize(info); - this.moveElementToOverlay(info); + this.setInitialSize( + info, + () => this.moveElementToOverlay(info) + ); // Update the container size after moving if there is size. if (info.size) { info.elementRef.nativeElement.parentElement.style.setProperty('--ig-size', info.size); @@ -1000,4 +1002,28 @@ export class IgxOverlayService implements OnDestroy { info.size = size; } } + + /** + * Measures the element's initial size and controls *when* the element is moved into the overlay outlet. + * + * The elements inherit constraining parent styles, so + * for some of them (e.g., Tooltip, Snackbar) their pre-move size is incorrect. + * Those can register an override via `OverlaySizeRegistry` to measure **after** moving to get an accurate size. + * + * - **Default**: Measures in-place (current parent), then moves to the overlay. + * + * @param info OverlayInfo for the content being attached. + * @param moveToOverlay Moves the element into the overlay. + */ + private setInitialSize(info: OverlayInfo, moveToOverlay: () => void): void { + const override = this.sizeRegistry.get(info); + if (override) { + override(info, moveToOverlay); + return; + } + + const elementRect = info.elementRef.nativeElement.getBoundingClientRect(); + info.initialSize = { width: elementRect.width, height: elementRect.height }; + moveToOverlay(); + } } diff --git a/projects/igniteui-angular/core/src/services/overlay/utilities.ts b/projects/igniteui-angular/core/src/services/overlay/utilities.ts index 13a954ce26d..38b45d8631b 100644 --- a/projects/igniteui-angular/core/src/services/overlay/utilities.ts +++ b/projects/igniteui-angular/core/src/services/overlay/utilities.ts @@ -1,10 +1,40 @@ import { AnimationReferenceMetadata } from '@angular/animations'; -import { ComponentRef, Directive, ElementRef, inject, Injector, NgZone } from '@angular/core'; +import { ComponentRef, Directive, ElementRef, inject, Injectable, Injector, NgZone } from '@angular/core'; import { CancelableBrowserEventArgs, CancelableEventArgs, cloneValue, IBaseEventArgs } from '../../core/utils'; import { AnimationPlayer } from '../animation/animation'; import { IPositionStrategy } from './position/IPositionStrategy'; import { IScrollStrategy } from './scroll'; +type SetInitialSizeFn = (info: OverlayInfo, moveToOverlay: () => void) => void; + +/** + * Maps a host `HTMLElement` to a sizing strategy (`SetInitialSizeFn`). + * + * @hidden + * @internal + */ + +@Injectable({ providedIn: 'root' }) +export class OverlaySizeRegistry { + private readonly map = new Map(); + + public register(host: HTMLElement, fn: SetInitialSizeFn): void { + this.map.set(host, fn); + } + + public clear(host: HTMLElement): void { + this.map.delete(host); + } + + public get(info: OverlayInfo): SetInitialSizeFn | undefined { + if (!info.elementRef || !info.elementRef.nativeElement) { + return; + } + + return this.map.get(info.elementRef.nativeElement); + } +} + /** * Mark an element as an igxOverlay outlet container. * Directive instance is exported as `overlay-outlet` to be assigned to templates variables: diff --git a/projects/igniteui-angular/core/src/services/public_api.ts b/projects/igniteui-angular/core/src/services/public_api.ts index 69ae76e9358..498bda162d6 100644 --- a/projects/igniteui-angular/core/src/services/public_api.ts +++ b/projects/igniteui-angular/core/src/services/public_api.ts @@ -9,7 +9,7 @@ export * from './overlay/scroll'; export { AbsolutePosition, ConnectedFit, HorizontalAlignment, OffsetMode, OverlayAnimationEventArgs, OverlayCancelableEventArgs, OverlayClosingEventArgs, OverlayCreateSettings, OverlayEventArgs, OverlaySettings, Point, PositionSettings, RelativePosition, RelativePositionStrategy, Size, VerticalAlignment, Util, - IgxOverlayOutletDirective + IgxOverlayOutletDirective, OverlayInfo, OverlaySizeRegistry } from './overlay/utilities'; export * from './transaction/base-transaction'; export * from './transaction/hierarchical-transaction'; diff --git a/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.spec.ts b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.spec.ts index 1846182d6de..b35ed1c4fd0 100644 --- a/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.spec.ts +++ b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.spec.ts @@ -2,7 +2,7 @@ import { DebugElement } from '@angular/core'; import { fakeAsync, TestBed, tick, flush, waitForAsync, ComponentFixture } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { IgxTooltipSingleTargetComponent, IgxTooltipMultipleTargetsComponent, IgxTooltipPlainStringComponent, IgxTooltipWithToggleActionComponent, IgxTooltipMultipleTooltipsComponent, IgxTooltipWithCloseButtonComponent, IgxTooltipWithNestedContentComponent, IgxTooltipNestedTooltipsComponent } from '../../../../test-utils/tooltip-components.spec'; +import { IgxTooltipSingleTargetComponent, IgxTooltipMultipleTargetsComponent, IgxTooltipPlainStringComponent, IgxTooltipWithToggleActionComponent, IgxTooltipMultipleTooltipsComponent, IgxTooltipWithCloseButtonComponent, IgxTooltipWithNestedContentComponent, IgxTooltipNestedTooltipsComponent, IgxTooltipSizeComponent } from '../../../../test-utils/tooltip-components.spec'; import { UIInteractions } from '../../../../test-utils/ui-interactions.spec'; import { HorizontalAlignment, VerticalAlignment, AutoPositionStrategy } from '../../../../core/src/services/public_api'; import { IgxTooltipDirective } from './tooltip.directive'; @@ -30,7 +30,8 @@ describe('IgxTooltip', () => { IgxTooltipWithToggleActionComponent, IgxTooltipWithCloseButtonComponent, IgxTooltipWithNestedContentComponent, - IgxTooltipNestedTooltipsComponent + IgxTooltipNestedTooltipsComponent, + IgxTooltipSizeComponent ] }).compileComponents(); UIInteractions.clearOverlay(); @@ -996,6 +997,31 @@ describe('IgxTooltip', () => { expect(fix.componentInstance.toggleDir.collapsed).toBe(false); })); + + it('correctly sizes the tooltip/overlay content when inside an element - issue #16458', fakeAsync(() => { + const fixture = TestBed.createComponent(IgxTooltipSizeComponent); + fixture.detectChanges(); + + fixture.componentInstance.target1.showTooltip(); + fixture.componentInstance.target2.showTooltip(); + fixture.componentInstance.target3.showTooltip(); + flush(); + + const tooltip1Rect = fixture.componentInstance.tooltip1.element.getBoundingClientRect(); + const tooltip2Rect = fixture.componentInstance.tooltip2.element.getBoundingClientRect(); + const tooltip3Rect = fixture.componentInstance.tooltip3.element.getBoundingClientRect(); + + const tooltip1ParentRect = fixture.componentInstance.tooltip1.element.parentElement.getBoundingClientRect(); + const tooltip2ParentRect = fixture.componentInstance.tooltip2.element.parentElement.getBoundingClientRect(); + const tooltip3ParentRect = fixture.componentInstance.tooltip3.element.parentElement.getBoundingClientRect(); + + expect(tooltip1Rect.width).toEqual(tooltip1ParentRect.width); + expect(tooltip1Rect.height).toEqual(tooltip1ParentRect.height); + expect(tooltip2Rect.width).toEqual(tooltip2ParentRect.width); + expect(tooltip2Rect.height).toEqual(tooltip2ParentRect.height); + expect(tooltip3Rect.width).toEqual(tooltip3ParentRect.width); + expect(tooltip3Rect.height).toEqual(tooltip3ParentRect.height); + })); }); describe('Tooltip Sticky with Close Button', () => { diff --git a/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.ts b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.ts index 626dab2ad25..57950708227 100644 --- a/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip.directive.ts @@ -3,8 +3,9 @@ import { OnDestroy, inject, DOCUMENT, HostListener, Renderer2, AfterViewInit, + OnInit, } from '@angular/core'; -import { OverlaySettings, PlatformUtil } from 'igniteui-angular/core'; +import { OverlayInfo, OverlaySettings, OverlaySizeRegistry, PlatformUtil } from 'igniteui-angular/core'; import { IgxToggleDirective } from '../toggle/toggle.directive'; import { IgxTooltipTargetDirective } from './tooltip-target.directive'; import { Subject, takeUntil } from 'rxjs'; @@ -29,7 +30,7 @@ let NEXT_ID = 0; selector: '[igxTooltip]', standalone: true }) -export class IgxTooltipDirective extends IgxToggleDirective implements AfterViewInit, OnDestroy { +export class IgxTooltipDirective extends IgxToggleDirective implements OnInit, AfterViewInit, OnDestroy { /** * @hidden */ @@ -119,6 +120,7 @@ export class IgxTooltipDirective extends IgxToggleDirective implements AfterView private _document = inject(DOCUMENT); private _renderer = inject(Renderer2); private _platformUtil = inject(PlatformUtil); + private _sizeRegistry = inject(OverlaySizeRegistry); /** @hidden */ constructor() { @@ -133,6 +135,12 @@ export class IgxTooltipDirective extends IgxToggleDirective implements AfterView }); } + /** @hidden */ + public override ngOnInit() { + super.ngOnInit(); + this._sizeRegistry.register(this.element, this.setInitialSize); + } + /** @hidden */ public ngAfterViewInit(): void { if (this._platformUtil.isBrowser) { @@ -151,6 +159,8 @@ export class IgxTooltipDirective extends IgxToggleDirective implements AfterView if (this.arrow) { this._removeArrow(); } + + this._sizeRegistry.clear(this.element); } /** @@ -226,4 +236,14 @@ export class IgxTooltipDirective extends IgxToggleDirective implements AfterView private onDocumentTouchStart(event) { this.tooltipTarget?.onDocumentTouchStart(event); } + + /** + * Measures **after** moving the element into the overlay outlet so that parent + * style constraints do not affect the initial size. + */ + private setInitialSize = (info: OverlayInfo, moveToOverlay: () => void) => { + moveToOverlay(); + const elementRect = info.elementRef.nativeElement.getBoundingClientRect(); + info.initialSize = { width: elementRect.width, height: elementRect.height }; + } } diff --git a/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.spec.ts b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.spec.ts index d8cf22d0adb..01bdb10e8ea 100644 --- a/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.spec.ts +++ b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.spec.ts @@ -14,7 +14,8 @@ describe('IgxSnackbar', () => { imports: [ NoopAnimationsModule, SnackbarInitializeTestComponent, - SnackbarCustomContentComponent + SnackbarCustomContentComponent, + SnackbarSizeTestComponent ] }).compileComponents(); })); @@ -183,6 +184,28 @@ describe('IgxSnackbar', () => { expect(customPositionSettings.openAnimation.options.params).toEqual({duration: '1000ms'}); expect(customPositionSettings.minSize).toEqual({height: 100, width: 100}); }); + + it('correctly sizes the snackbar/overlay content when inside an element - issue #16458', () => { + const fix = TestBed.createComponent(SnackbarSizeTestComponent); + fix.detectChanges(); + snackbar = fix.componentInstance.snackbar; + + const parentDivRect = snackbar.element.parentElement.getBoundingClientRect(); + expect(parentDivRect.width).toBe(600); + + snackbar.open(); + fix.detectChanges(); + + const snackbarRect = snackbar.element.getBoundingClientRect(); + const overlayContentRect = snackbar.element.parentElement.getBoundingClientRect(); + const { marginLeft, marginRight, paddingLeft, paddingRight } = getComputedStyle(snackbar.element); + const horizontalMargins = parseFloat(marginLeft) + parseFloat(marginRight); + const horizontalPaddings = parseFloat(paddingLeft) + parseFloat(paddingRight); + const contentWidth = 200; + + expect(snackbarRect.width).toEqual(contentWidth + horizontalPaddings); + expect(overlayContentRect.width).toEqual(snackbarRect.width + horizontalMargins); + }); }); describe('IgxSnackbar with custom content', () => { @@ -273,3 +296,17 @@ class SnackbarCustomContentComponent { @ViewChild(IgxSnackbarComponent, { static: true }) public snackbar: IgxSnackbarComponent; public text: string; } + +@Component({ + template: ` +
+ +
Snackbar Message
+
+
+ `, + imports: [IgxSnackbarComponent] +}) +class SnackbarSizeTestComponent { + @ViewChild(IgxSnackbarComponent, { static: true }) public snackbar: IgxSnackbarComponent; +} diff --git a/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.ts b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.ts index cd7b50468fe..37aeed85ab0 100644 --- a/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.ts +++ b/projects/igniteui-angular/snackbar/src/snackbar/snackbar.component.ts @@ -1,15 +1,19 @@ import { useAnimation } from '@angular/animations'; import { Component, + DOCUMENT, EventEmitter, HostBinding, + inject, Input, + OnDestroy, OnInit, Output } from '@angular/core'; import { takeUntil } from 'rxjs/operators'; import { ContainerPositionStrategy, GlobalPositionStrategy, HorizontalAlignment, - PositionSettings, VerticalAlignment } from 'igniteui-angular/core'; + OverlayInfo, + OverlaySizeRegistry, PositionSettings, VerticalAlignment } from 'igniteui-angular/core'; import { ToggleViewEventArgs, IgxButtonDirective, IgxNotificationsDirective } from 'igniteui-angular/directives'; import { fadeIn, fadeOut } from 'igniteui-angular/animations'; @@ -36,8 +40,10 @@ let NEXT_ID = 0; templateUrl: 'snackbar.component.html', imports: [IgxButtonDirective] }) -export class IgxSnackbarComponent extends IgxNotificationsDirective - implements OnInit { +export class IgxSnackbarComponent extends IgxNotificationsDirective implements OnInit, OnDestroy { + private _document = inject(DOCUMENT); + private _sizeRegistry = inject(OverlaySizeRegistry); + /** * Sets/gets the `id` of the snackbar. * If not set, the `id` of the first snackbar component will be `"igx-snackbar-0"`; @@ -196,5 +202,29 @@ export class IgxSnackbarComponent extends IgxNotificationsDirective const closedEventArgs: ToggleViewEventArgs = { owner: this, id: this._overlayId }; this.animationDone.emit(closedEventArgs); }); + + this._sizeRegistry.register(this.element, this.setInitialSize); + } + + /** + * @hidden + */ + public override ngOnDestroy() { + super.ngOnDestroy(); + this._sizeRegistry.clear(this.element); + } + + /** + * Measures **after** moving the element into the overlay outlet so that parent + * style constraints do not affect the initial size. + */ + private setInitialSize = (info: OverlayInfo, moveToOverlay: () => void) => { + moveToOverlay(); + const elementRect = info.elementRef.nativeElement.getBoundingClientRect(); + // Needs full element width (margins included) to set proper width for the overlay container. + // Otherwise, the snackbar appears smaller and the text inside it might be misaligned. + const styles = this._document.defaultView.getComputedStyle(info.elementRef.nativeElement); + const horizontalMargins = parseFloat(styles.marginLeft) + parseFloat(styles.marginRight); + info.initialSize = { width: elementRect.width + horizontalMargins, height: elementRect.height }; } } diff --git a/projects/igniteui-angular/test-utils/tooltip-components.spec.ts b/projects/igniteui-angular/test-utils/tooltip-components.spec.ts index 01f45faf660..d44881471ff 100644 --- a/projects/igniteui-angular/test-utils/tooltip-components.spec.ts +++ b/projects/igniteui-angular/test-utils/tooltip-components.spec.ts @@ -1,5 +1,6 @@ import { Component, TemplateRef, ViewChild } from '@angular/core'; -import { IgxToggleActionDirective, IgxToggleDirective, IgxTooltipDirective, IgxTooltipTargetDirective, ITooltipHideEventArgs, ITooltipShowEventArgs } from 'igniteui-angular/directives'; +import { IgxButtonDirective, IgxIconButtonDirective, IgxToggleActionDirective, IgxToggleDirective, IgxTooltipDirective, IgxTooltipTargetDirective, ITooltipHideEventArgs, ITooltipShowEventArgs } from 'igniteui-angular/directives'; +import { IgxIconComponent } from 'igniteui-angular/icon'; @Component({ @@ -161,8 +162,7 @@ export class IgxTooltipWithCloseButtonComponent { `, - imports: [IgxTooltipDirective, IgxTooltipTargetDirective], - standalone: true + imports: [IgxTooltipDirective, IgxTooltipTargetDirective] }) export class IgxTooltipWithNestedContentComponent { @ViewChild(IgxTooltipDirective, { static: true }) public tooltip!: IgxTooltipDirective; @@ -195,8 +195,7 @@ export class IgxTooltipWithNestedContentComponent { `, - imports: [IgxTooltipDirective, IgxTooltipTargetDirective], - standalone: true + imports: [IgxTooltipDirective, IgxTooltipTargetDirective] }) export class IgxTooltipNestedTooltipsComponent { @ViewChild('targetLevel1', { read: IgxTooltipTargetDirective, static: true }) public targetLevel1: IgxTooltipTargetDirective; @@ -207,3 +206,31 @@ export class IgxTooltipNestedTooltipsComponent { @ViewChild('tooltipLevel2', { read: IgxTooltipDirective, static: true }) public tooltipLevel2: IgxTooltipDirective; @ViewChild('tooltipLevel3', { read: IgxTooltipDirective, static: true }) public tooltipLevel3: IgxTooltipDirective; } + +@Component({ + template: ` +
+
{{ message }}
+
+ + + `, + imports: [IgxTooltipDirective, IgxTooltipTargetDirective, IgxIconComponent, IgxIconButtonDirective, IgxButtonDirective] +}) +export class IgxTooltipSizeComponent { + @ViewChild('target1', { read: IgxTooltipTargetDirective, static: true }) public target1: IgxTooltipTargetDirective; + @ViewChild('target2', { read: IgxTooltipTargetDirective, static: true }) public target2: IgxTooltipTargetDirective; + @ViewChild('target3', { read: IgxTooltipTargetDirective, static: true }) public target3: IgxTooltipTargetDirective; + + @ViewChild('tooltip1', { read: IgxTooltipDirective, static: true }) public tooltip1: IgxTooltipDirective; + @ViewChild('tooltip2', { read: IgxTooltipDirective, static: true }) public tooltip2: IgxTooltipDirective; + @ViewChild('tooltip3', { read: IgxTooltipDirective, static: true }) public tooltip3: IgxTooltipDirective; + + public message: string = 'Long tooltip message for testing purposes'; +}