From 17d7523c3c2aedec82b6be53ab9adf08788ab7d9 Mon Sep 17 00:00:00 2001 From: m2rt Date: Tue, 10 Mar 2026 08:14:40 +0200 Subject: [PATCH] fix(tooltip): correct event handling on touch devices #348 --- .../tooltip-trigger.component.spec.ts | 58 +++++++++++++++++++ .../tooltip-trigger.component.ts | 33 ++++++++--- .../overlay/tooltip/tooltip.component.scss | 1 + 3 files changed, 84 insertions(+), 8 deletions(-) diff --git a/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.spec.ts b/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.spec.ts index 608c9db6d..57c281d6a 100644 --- a/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.spec.ts +++ b/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.spec.ts @@ -92,6 +92,54 @@ describe("TooltipTriggerComponent", () => { hostEl.dispatchEvent(new Event("mouseenter")); expect(tooltip.showTooltip).not.toHaveBeenCalled(); }); + + it("should not call showTooltip after recent touchstart", () => { + tooltip.openWith = jest.fn(() => "both"); + + hostEl.dispatchEvent(new Event("touchstart")); + hostEl.dispatchEvent(new Event("mouseenter")); + + expect(tooltip.showTooltip).not.toHaveBeenCalled(); + }); + }); + + describe("touch interaction", () => { + it("should toggle tooltip on touchend regardless of openWith", () => { + tooltip.openWith = jest.fn(() => "hover"); + + hostEl.dispatchEvent(new Event("touchstart")); + hostEl.dispatchEvent(new Event("touchend")); + + expect(tooltip.toggleTooltip).toHaveBeenCalledTimes(1); + }); + + it("should not double-toggle on touch (click is ignored)", () => { + tooltip.openWith = jest.fn(() => "both"); + + hostEl.dispatchEvent(new Event("touchstart")); + hostEl.dispatchEvent(new Event("mouseenter")); + hostEl.dispatchEvent(new Event("focusin")); + hostEl.dispatchEvent(new Event("touchend")); + hostEl.click(); + + expect(tooltip.showTooltip).not.toHaveBeenCalled(); + expect(tooltip.toggleTooltip).toHaveBeenCalledTimes(1); + }); + + it("should reset isTouch flag after touchend timeout", () => { + jest.useFakeTimers(); + tooltip.openWith = jest.fn(() => "both"); + + hostEl.dispatchEvent(new Event("touchstart")); + hostEl.dispatchEvent(new Event("touchend")); + + jest.advanceTimersByTime(300); + + hostEl.dispatchEvent(new Event("mouseenter")); + expect(tooltip.showTooltip).toHaveBeenCalled(); + + jest.useRealTimers(); + }); }); describe("mouseleave", () => { @@ -128,6 +176,16 @@ describe("TooltipTriggerComponent", () => { jest.advanceTimersByTime(100); expect(tooltip.hideTooltip).not.toHaveBeenCalled(); }); + + it("should not call hideTooltip after recent touchstart", () => { + tooltip.openWith = jest.fn(() => "both"); + + hostEl.dispatchEvent(new Event("touchstart")); + hostEl.dispatchEvent(new Event("mouseleave")); + + jest.advanceTimersByTime(100); + expect(tooltip.hideTooltip).not.toHaveBeenCalled(); + }); }); describe("focusin", () => { diff --git a/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.ts b/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.ts index cce6165bd..0999b7e2a 100644 --- a/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.ts +++ b/tedi/components/overlay/tooltip/tooltip-trigger/tooltip-trigger.component.ts @@ -26,21 +26,33 @@ export class TooltipTriggerComponent implements AfterContentChecked { readonly tooltip = inject(TooltipComponent); private interactiveElement = signal(null); + private isTouch = false; + constructor() { effect(() => { const element = this.interactiveElement(); if (!element) return; - const descriptionId = this.tooltip.descriptionId; - const isOpen = this.tooltip.isOpen(); - - element.setAttribute("aria-describedby", descriptionId); - element.setAttribute("aria-expanded", String(isOpen)); + element.setAttribute("aria-describedby", this.tooltip.descriptionId); + element.setAttribute("aria-expanded", String(this.tooltip.isOpen())); }); } + @HostListener("touchstart") + onTouchStart() { + this.isTouch = true; + } + + @HostListener("touchend") + onTouchEnd() { + this.tooltip.toggleTooltip(); + setTimeout(() => (this.isTouch = false), 300); + } + @HostListener("click") onClick() { + if (this.isTouch) return; + if ( this.tooltip.openWith() === "both" || this.tooltip.openWith() === "click" @@ -51,6 +63,8 @@ export class TooltipTriggerComponent implements AfterContentChecked { @HostListener("mouseenter") onMouseEnter() { + if (this.isTouch) return; + if ( this.tooltip.openWith() === "both" || this.tooltip.openWith() === "hover" @@ -61,6 +75,8 @@ export class TooltipTriggerComponent implements AfterContentChecked { @HostListener("mouseleave") onMouseLeave() { + if (this.isTouch) return; + if ( this.tooltip.openWith() === "both" || this.tooltip.openWith() === "hover" @@ -75,6 +91,8 @@ export class TooltipTriggerComponent implements AfterContentChecked { @HostListener("focusin") onFocusIn() { + if (this.isTouch) return; + if ( this.tooltip.openWith() === "both" || this.tooltip.openWith() === "hover" @@ -85,9 +103,8 @@ export class TooltipTriggerComponent implements AfterContentChecked { @HostListener("focusout") onFocusOut() { - if (this.tooltip.isContentHovered()) { - return; - } + if (this.isTouch) return; + if (this.tooltip.isContentHovered()) return; if ( this.tooltip.openWith() === "both" || diff --git a/tedi/components/overlay/tooltip/tooltip.component.scss b/tedi/components/overlay/tooltip/tooltip.component.scss index fec2e1add..4d138d827 100644 --- a/tedi/components/overlay/tooltip/tooltip.component.scss +++ b/tedi/components/overlay/tooltip/tooltip.component.scss @@ -12,6 +12,7 @@ tedi-tooltip { float-ui-content { .float-ui-container-tooltip { z-index: var(--z-index-tooltip); + width: max-content; padding: 0; border: 0; border-radius: var(--popover-radius-rounded);