Skip to content

fix(tooltip): make touch events passive - 19.2.x #15813

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
May 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen
return;
}

this.target.tooltipTarget = this;

const showingArgs = { target: this, tooltip: this.target, cancel: false };
this.tooltipShow.emit(showingArgs);

Expand Down Expand Up @@ -258,7 +260,6 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen
/**
* @hidden
*/
@HostListener('touchstart')
public onTouchStart() {
if (this.tooltipDisabled) {
return;
Expand All @@ -270,7 +271,6 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen
/**
* @hidden
*/
@HostListener('document:touchstart', ['$event'])
public onDocumentTouchStart(event) {
if (this.tooltipDisabled) {
return;
Expand Down Expand Up @@ -301,20 +301,27 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen
this._overlayDefaults.closeOnEscape = true;

this.target.closing.pipe(takeUntil(this.destroy$)).subscribe((event) => {
if (this.target.tooltipTarget !== this) {
return;
}

const hidingArgs = { target: this, tooltip: this.target, cancel: false };
this.tooltipHide.emit(hidingArgs);

if (hidingArgs.cancel) {
event.cancel = true;
}
});

this.nativeElement.addEventListener('touchstart', this.onTouchStart = this.onTouchStart.bind(this), { passive: true });
}

/**
* @hidden
*/
public ngOnDestroy() {
this.hideTooltip();
this.nativeElement.removeEventListener('touchstart', this.onTouchStart);
this.destroy$.next();
this.destroy$.complete();
}
Expand All @@ -334,6 +341,7 @@ export class IgxTooltipTargetDirective extends IgxToggleActionDirective implemen
this.target.forceClose(this.mergedOverlaySettings);
this.target.toBeHidden = false;
}
this.target.tooltipTarget = this;

const showingArgs = { target: this, tooltip: this.target, cancel: false };
this.tooltipShow.emit(showingArgs);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fakeAsync, TestBed, tick, flush, waitForAsync } from '@angular/core/testing';
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 } from '../../test-utils/tooltip-components.spec';
Expand All @@ -11,10 +12,10 @@ const HIDDEN_TOOLTIP_CLASS = 'igx-tooltip--hidden';
const TOOLTIP_CLASS = 'igx-tooltip';

describe('IgxTooltip', () => {
let fix;
let tooltipNativeElement;
let fix: ComponentFixture<any>;
let tooltipNativeElement: HTMLElement;
let tooltipTarget: IgxTooltipTargetDirective;
let button;
let button: DebugElement;

beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
Expand Down Expand Up @@ -273,7 +274,7 @@ describe('IgxTooltip', () => {
flush();

verifyTooltipVisibility(tooltipNativeElement, tooltipTarget, true);

fix.componentInstance.showButton = false;
fix.detectChanges();
flush();
Expand Down Expand Up @@ -492,8 +493,8 @@ describe('IgxTooltip', () => {
fix = TestBed.createComponent(IgxTooltipMultipleTargetsComponent);
fix.detectChanges();
tooltipNativeElement = fix.debugElement.query(By.directive(IgxTooltipDirective)).nativeElement;
targetOne = fix.componentInstance.targetOne as IgxTooltipTargetDirective;
targetTwo = fix.componentInstance.targetTwo as IgxTooltipTargetDirective;
targetOne = fix.componentInstance.targetOne;
targetTwo = fix.componentInstance.targetTwo;
buttonOne = fix.debugElement.query(By.css('.buttonOne'));
buttonTwo = fix.debugElement.query(By.css('.buttonTwo'));
}));
Expand Down Expand Up @@ -560,6 +561,64 @@ describe('IgxTooltip', () => {
// Tooltip is NOT visible and positioned relative to buttonOne
verifyTooltipPosition(tooltipNativeElement, buttonOne, false);
}));

it('Should not call `hideTooltip` multiple times on document:touchstart', fakeAsync(() => {
spyOn(targetOne, 'hideTooltip').and.callThrough();
spyOn(targetTwo, 'hideTooltip').and.callThrough();

touchElement(buttonOne);
tick(500);

const dummyDiv = fix.debugElement.query(By.css('.dummyDiv'));
touchElement(dummyDiv);
flush();

expect(targetOne.hideTooltip).toHaveBeenCalledTimes(1);
expect(targetTwo.hideTooltip).not.toHaveBeenCalled();
}));

it('should not emit tooltipHide event multiple times', fakeAsync(() => {
spyOn(targetOne.tooltipHide, 'emit');
spyOn(targetTwo.tooltipHide, 'emit');

hoverElement(buttonOne);
flush();

const tooltipHideArgsTargetOne = { target: targetOne, tooltip: fix.componentInstance.tooltip, cancel: false };
const tooltipHideArgsTargetTwo = { target: targetTwo, tooltip: fix.componentInstance.tooltip, cancel: false };

unhoverElement(buttonOne);
tick(500);
expect(targetOne.tooltipHide.emit).toHaveBeenCalledOnceWith(tooltipHideArgsTargetOne);
expect(targetTwo.tooltipHide.emit).not.toHaveBeenCalled();
flush();

hoverElement(buttonTwo);
flush();

unhoverElement(buttonTwo);
tick(500);
expect(targetOne.tooltipHide.emit).toHaveBeenCalledOnceWith(tooltipHideArgsTargetOne);
expect(targetTwo.tooltipHide.emit).toHaveBeenCalledOnceWith(tooltipHideArgsTargetTwo);
flush();
}));


it('IgxTooltip hides when touch one target, then another, then outside', fakeAsync(() => {
touchElement(targetOne);
flush();
verifyTooltipVisibility(tooltipNativeElement, targetOne, true);
verifyTooltipPosition(tooltipNativeElement, targetOne, true);

touchElement(targetTwo);
flush();
verifyTooltipVisibility(tooltipNativeElement, targetTwo, true);
verifyTooltipPosition(tooltipNativeElement, targetTwo, true);

touchElement(fix.debugElement);
flush();
verifyTooltipVisibility(tooltipNativeElement, targetTwo, false);
}));
});

describe('Tooltip integration', () => {
Expand Down Expand Up @@ -593,11 +652,15 @@ describe('IgxTooltip', () => {
});
});

const hoverElement = (element) => element.nativeElement.dispatchEvent(new MouseEvent('mouseenter'));
interface ElementRefLike {
nativeElement: HTMLElement
}

const hoverElement = (element: ElementRefLike) => element.nativeElement.dispatchEvent(new MouseEvent('mouseenter'));

const unhoverElement = (element) => element.nativeElement.dispatchEvent(new MouseEvent('mouseleave'));
const unhoverElement = (element: ElementRefLike) => element.nativeElement.dispatchEvent(new MouseEvent('mouseleave'));

const touchElement = (element) => element.nativeElement.dispatchEvent(new TouchEvent('touchstart', { bubbles: true }));
const touchElement = (element: ElementRefLike) => element.nativeElement.dispatchEvent(new TouchEvent('touchstart', { bubbles: true }));

const verifyTooltipVisibility = (tooltipNativeElement, tooltipTarget, shouldBeVisible: boolean) => {
expect(tooltipNativeElement.classList.contains(TOOLTIP_CLASS)).toBe(shouldBeVisible);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {
Directive, ElementRef, Input, ChangeDetectorRef, Optional, HostBinding, Inject
Directive, ElementRef, Input, ChangeDetectorRef, Optional, HostBinding, Inject, OnDestroy
} from '@angular/core';
import { IgxOverlayService } from '../../services/overlay/overlay';
import { OverlaySettings } from '../../services/public_api';
import { IgxNavigationService } from '../../core/navigation';
import { IgxToggleDirective } from '../toggle/toggle.directive';
import { IgxTooltipTargetDirective } from './tooltip-target.directive';
import { Subject, takeUntil } from 'rxjs';

let NEXT_ID = 0;
/**
Expand All @@ -26,7 +28,7 @@ let NEXT_ID = 0;
selector: '[igxTooltip]',
standalone: true
})
export class IgxTooltipDirective extends IgxToggleDirective {
export class IgxTooltipDirective extends IgxToggleDirective implements OnDestroy {
/**
* @hidden
*/
Expand Down Expand Up @@ -102,6 +104,13 @@ export class IgxTooltipDirective extends IgxToggleDirective {
*/
public toBeShown = false;

/**
* @hidden
*/
public tooltipTarget: IgxTooltipTargetDirective;

private _destroy$ = new Subject<boolean>();

/** @hidden */
constructor(
elementRef: ElementRef,
Expand All @@ -110,6 +119,23 @@ export class IgxTooltipDirective extends IgxToggleDirective {
@Optional() navigationService: IgxNavigationService) {
// D.P. constructor duplication due to es6 compilation, might be obsolete in the future
super(elementRef, cdr, overlayService, navigationService);

this.onDocumentTouchStart = this.onDocumentTouchStart.bind(this);
this.overlayService.opening.pipe(takeUntil(this._destroy$)).subscribe(() => {
document.addEventListener('touchstart', this.onDocumentTouchStart, { passive: true });
});
this.overlayService.closed.pipe(takeUntil(this._destroy$)).subscribe(() => {
document.removeEventListener('touchstart', this.onDocumentTouchStart);
});
}

/** @hidden */
public override ngOnDestroy() {
super.ngOnDestroy();

document.removeEventListener('touchstart', this.onDocumentTouchStart);
this._destroy$.next(true);
this._destroy$.complete();
}

/**
Expand Down Expand Up @@ -154,4 +180,8 @@ export class IgxTooltipDirective extends IgxToggleDirective {
overlaySettings.positionStrategy.settings.closeAnimation = animation;
}
}

private onDocumentTouchStart(event) {
this.tooltipTarget?.onDocumentTouchStart(event);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { IgxToggleActionDirective, IgxToggleDirective } from '../directives/togg
@Component({
template: `
<div class="dummyDiv">dummy div for touch tests</div>

@if (showButton) {
<button [igxTooltipTarget]="tooltipRef" [tooltip]="'Infragistics Inc. HQ'"
(tooltipShow)="showing($event)" (tooltipHide)="hiding($event)"
Expand Down Expand Up @@ -42,6 +42,8 @@ export class IgxTooltipSingleTargetComponent {

@Component({
template: `
<div class="dummyDiv">dummy div for touch tests</div>

<button class="buttonOne" #targetOne="tooltipTarget" [igxTooltipTarget]="tooltipRef" style="margin: 100px">
Target One
</button>
Expand All @@ -57,7 +59,7 @@ export class IgxTooltipSingleTargetComponent {
imports: [IgxTooltipDirective, IgxTooltipTargetDirective]
})
export class IgxTooltipMultipleTargetsComponent {
@ViewChild('targetOne', { read: IgxTooltipTargetDirective, static: true }) public targetOne: IgxTooltipDirective;
@ViewChild('targetOne', { read: IgxTooltipTargetDirective, static: true }) public targetOne: IgxTooltipTargetDirective;
@ViewChild('targetTwo', { read: IgxTooltipTargetDirective, static: true }) public targetTwo: IgxTooltipTargetDirective;
@ViewChild(IgxTooltipDirective, { static: true }) public tooltip: IgxTooltipDirective;
}
Expand Down
Loading