From 947ff54bdce6241c3d370d6072177c2ce6e9238b Mon Sep 17 00:00:00 2001 From: Viktor Kombov Date: Sat, 1 Aug 2026 15:08:30 +0300 Subject: [PATCH] fix(tooltip): prevent delayed tooltip after target moves --- .../tooltip/tooltip-target.directive.ts | 51 +++++++++++++++++++ .../tooltip/tooltip.directive.spec.ts | 50 +++++++++++++++++- 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/projects/igniteui-angular/directives/src/directives/tooltip/tooltip-target.directive.ts b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip-target.directive.ts index 77bca12da8a..0010b1d113b 100644 --- a/projects/igniteui-angular/directives/src/directives/tooltip/tooltip-target.directive.ts +++ b/projects/igniteui-angular/directives/src/directives/tooltip/tooltip-target.directive.ts @@ -27,6 +27,13 @@ export interface ITooltipHideEventArgs extends IBaseEventArgs { cancel: boolean; } +const HOVER_SHOW_TRIGGERS = new Set(['mouseenter', 'mouseover', 'pointerenter', 'pointerover']); + +interface TooltipPointerPosition { + clientX: number; + clientY: number; +} + /** * **Ignite UI for Angular Tooltip Target** - * [Documentation](https://www.infragistics.com/products/ignite-ui-angular/angular/components/tooltip) @@ -381,9 +388,16 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen private _showTriggers = new Set(['pointerenter']); private _hideTriggers = new Set(['pointerleave', 'click']); private _pendingShowTrigger: string | null = null; + private _pointerPosition: TooltipPointerPosition | null = null; private _abortController = new AbortController(); + private _onPointerMove = (event: PointerEvent): void => { + if (this._pointerPosition) { + this._pointerPosition = { clientX: event.clientX, clientY: event.clientY }; + } + }; + /** * @hidden */ @@ -506,6 +520,7 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen for (const each of this._hideTriggers) { this.nativeElement.addEventListener(each, this.onHide, options); } + this.nativeElement.addEventListener('pointermove', this._onPointerMove, options); } private removeEventListeners(): void { @@ -562,14 +577,49 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen this._evaluateStickyState(); this._pendingShowTrigger = triggerEvent?.type ?? null; + this._pointerPosition = withDelay && this.showDelay > 0 + ? this._getPointerPosition(triggerEvent) + : null; this.target.timeoutId = setTimeout(() => { // Call open() of IgxTooltipDirective + const pointerPosition = this._pointerPosition; + this.target.timeoutId = null; this._pendingShowTrigger = null; + this._pointerPosition = null; + + if (pointerPosition && !this._isPointerOverTarget(pointerPosition)) { + return; + } + this.target.open(this._mergedOverlaySettings); }, withDelay ? this.showDelay : 0); } + private _getPointerPosition(event?: Event): TooltipPointerPosition | null { + if (!event || !HOVER_SHOW_TRIGGERS.has(event.type)) { + return null; + } + + const pointerEvent = event as MouseEvent; + if (typeof pointerEvent.clientX !== 'number' || typeof pointerEvent.clientY !== 'number') { + return null; + } + if (!event.isTrusted && pointerEvent.clientX === 0 && pointerEvent.clientY === 0) { + return null; + } + + return { clientX: pointerEvent.clientX, clientY: pointerEvent.clientY }; + } + + private _isPointerOverTarget(position: TooltipPointerPosition): boolean { + const root = this.nativeElement.getRootNode() as DocumentOrShadowRoot; + const hitTestRoot = typeof root.elementFromPoint === 'function' + ? root + : this.nativeElement.ownerDocument; + const element = hitTestRoot.elementFromPoint(position.clientX, position.clientY); + return !!element && this.nativeElement.contains(element); + } private _showOnInteraction(triggerEvent?: Event): void { this._stopTimeoutAndAnimation(); @@ -620,6 +670,7 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen clearTimeout(this.target.timeoutId); this.target.timeoutId = null; this._pendingShowTrigger = null; + this._pointerPosition = null; } /** 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 318b4e0f1e6..ddd6ab6fcf0 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 @@ -167,6 +167,53 @@ describe('IgxTooltip', () => { verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); })); + it('should not show a delayed tooltip after its target moves away from the pointer', fakeAsync(() => { + tooltipTarget.showDelay = 500; + const target = button.nativeElement; + target.style.position = 'fixed'; + target.style.top = '20px'; + target.style.left = '20px'; + target.style.width = '100px'; + target.style.height = '40px'; + target.style.zIndex = '9999'; + const bounds = target.getBoundingClientRect(); + const pointer = { + clientX: bounds.left + bounds.width / 2, + clientY: bounds.top + bounds.height / 2 + }; + + expect(target.contains(document.elementFromPoint(pointer.clientX, pointer.clientY))).toBeTrue(); + hoverElement(button, pointer); + target.style.transform = 'translateX(200px)'; + expect(target.contains(document.elementFromPoint(pointer.clientX, pointer.clientY))).toBeFalse(); + + tick(500); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, false); + })); + + it('should not hit-test hover interactions without a show delay', fakeAsync(() => { + tooltipTarget.showDelay = 0; + const elementFromPointSpy = spyOn(document, 'elementFromPoint'); + + hoverElement(button, { clientX: 20, clientY: 30 }); + tick(); + + expect(elementFromPointSpy).not.toHaveBeenCalled(); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + })); + + it('should validate a delayed tooltip against the latest pointer position', fakeAsync(() => { + tooltipTarget.showDelay = 500; + const elementFromPointSpy = spyOn(document, 'elementFromPoint').and.returnValue(button.nativeElement); + + hoverElement(button, { clientX: 20, clientY: 30 }); + button.nativeElement.dispatchEvent(new PointerEvent('pointermove', { clientX: 40, clientY: 50 })); + tick(500); + + expect(elementFromPointSpy).toHaveBeenCalledWith(40, 50); + verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true); + })); + it('IgxTooltip mouse interaction respects hideDelay', fakeAsync(() => { tooltipTarget.hideDelay = 700; fix.detectChanges(); @@ -1169,7 +1216,8 @@ interface ElementRefLike { nativeElement: HTMLElement } -const hoverElement = (element: ElementRefLike) => element.nativeElement.dispatchEvent(new MouseEvent('pointerenter')); +const hoverElement = (element: ElementRefLike, eventInit: MouseEventInit = {}) => + element.nativeElement.dispatchEvent(new MouseEvent('pointerenter', eventInit)); const unhoverElement = (element: ElementRefLike) => element.nativeElement.dispatchEvent(new MouseEvent('pointerleave'));