From c5f4982b540eaaa930a1fb029f100be375b2f6f4 Mon Sep 17 00:00:00 2001 From: Philipp Schneider Date: Wed, 11 Feb 2026 09:52:33 +0100 Subject: [PATCH 1/8] test --- src/core/chart-api/chart-extra-pointer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/chart-api/chart-extra-pointer.tsx b/src/core/chart-api/chart-extra-pointer.tsx index 305f3e12..3b8173c2 100644 --- a/src/core/chart-api/chart-extra-pointer.tsx +++ b/src/core/chart-api/chart-extra-pointer.tsx @@ -34,12 +34,12 @@ export class ChartExtraPointer { public onChartLoad = (chart: Highcharts.Chart) => { chart.container.addEventListener("mousemove", this.onChartMousemove); - chart.container.addEventListener("mouseout", this.onChartMouseout); + chart.container.addEventListener("mouseleave", this.onChartMouseout); }; public onChartDestroy = () => { this.context.chartOrNull?.container?.removeEventListener("mousemove", this.onChartMousemove); - this.context.chartOrNull?.container?.removeEventListener("mouseout", this.onChartMouseout); + this.context.chartOrNull?.container?.removeEventListener("mouseleave", this.onChartMouseout); }; // This event is triggered by Highcharts when the cursor is over a Highcharts point. We leave this to From 286234642a5324d3e1e025a56f892b5a3a0c3ce4 Mon Sep 17 00:00:00 2001 From: Philipp Schneider Date: Wed, 18 Feb 2026 10:04:53 +0100 Subject: [PATCH 2/8] line chart fix --- .../__tests__/chart-core-tooltip.test.tsx | 42 +++++++++++++++++++ src/core/chart-api/chart-extra-pointer.tsx | 10 ++++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/core/__tests__/chart-core-tooltip.test.tsx b/src/core/__tests__/chart-core-tooltip.test.tsx index cb2559a3..cf77f402 100644 --- a/src/core/__tests__/chart-core-tooltip.test.tsx +++ b/src/core/__tests__/chart-core-tooltip.test.tsx @@ -819,6 +819,48 @@ describe("CoreChart: tooltip", () => { }); }); + test("hides tooltip when mouse moves outside plot area to the left", async () => { + const onHighlight = vi.fn(); + const onClearHighlight = vi.fn(); + const { wrapper } = renderChart({ + highcharts, + options: { + series: lineSeries, + chart: { + events: { + load() { + this.plotTop = 0; + this.plotLeft = 50; + this.plotWidth = 100; + this.plotHeight = 100; + }, + }, + }, + }, + onHighlight, + onClearHighlight, + getTooltipContent: () => ({ + header: () => "Tooltip title", + body: () => "Tooltip body", + }), + }); + + // Move mouse inside plot area to show tooltip + act(() => hc.getChart().container.dispatchEvent(createMouseMoveEvent({ pageX: 75, pageY: 50 }))); + + await waitFor(() => { + expect(wrapper.findTooltip()).not.toBe(null); + }); + + // Move mouse to the left, outside the plot area (plotLeft=50, so pageX=30 is outside) + act(() => hc.getChart().container.dispatchEvent(createMouseMoveEvent({ pageX: 30, pageY: 50 }))); + + await waitFor(() => { + expect(onClearHighlight).toHaveBeenCalled(); + expect(wrapper.findTooltip()).toBe(null); + }); + }); + describe("Escape key dismissal", () => { test("dismisses hover tooltip with Escape key when keyboard navigation is disabled", async () => { const { wrapper } = renderChart({ diff --git a/src/core/chart-api/chart-extra-pointer.tsx b/src/core/chart-api/chart-extra-pointer.tsx index 3b8173c2..d12af5b7 100644 --- a/src/core/chart-api/chart-extra-pointer.tsx +++ b/src/core/chart-api/chart-extra-pointer.tsx @@ -110,10 +110,16 @@ export class ChartExtraPointer { this.setHoveredGroup(matchedGroup); } // If the plotX, plotY are outside of the series area (e.g. if the pointer is above axis titles or ticks), - // we clear the group hover state and trigger the on-hover-lost after a short delay. + // we immediately clear all hover state. Unlike transitions between points/groups within the plot area, + // there is no need to debounce here as the cursor has definitively left the data region. else { + this.hoveredPoint = null; this.hoveredGroup = null; - this.clearHover(); + this.hoverLostCall.cancelPrevious(); + if (!this.tooltipHovered) { + this.handlers.onHoverLost(); + this.applyCursorStyle(); + } } }; From f840145fe3c803abc2e50ecca75632ffdde8eddf Mon Sep 17 00:00:00 2001 From: Philipp Schneider Date: Thu, 19 Feb 2026 12:03:37 +0100 Subject: [PATCH 3/8] fix line chart --- .../__tests__/chart-core-tooltip.test.tsx | 4 +++- src/core/chart-api/chart-extra-pointer.tsx | 22 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/core/__tests__/chart-core-tooltip.test.tsx b/src/core/__tests__/chart-core-tooltip.test.tsx index cf77f402..95fff339 100644 --- a/src/core/__tests__/chart-core-tooltip.test.tsx +++ b/src/core/__tests__/chart-core-tooltip.test.tsx @@ -181,7 +181,9 @@ describe("CoreChart: tooltip", () => { expect(wrapper.findTooltip()!.findFooter()!.getElement().textContent).toBe("Tooltip footer"); }); - act(() => hc.getChart().container.dispatchEvent(new MouseEvent("mouseout", { bubbles: true, cancelable: true }))); + act(() => + hc.getChart().container.dispatchEvent(new MouseEvent("mouseleave", { bubbles: false, cancelable: false })), + ); await waitFor(() => { expect(onClearHighlight).toHaveBeenCalled(); diff --git a/src/core/chart-api/chart-extra-pointer.tsx b/src/core/chart-api/chart-extra-pointer.tsx index d12af5b7..f0da44cb 100644 --- a/src/core/chart-api/chart-extra-pointer.tsx +++ b/src/core/chart-api/chart-extra-pointer.tsx @@ -65,11 +65,16 @@ export class ChartExtraPointer { this.hoveredGroup = null; }; - // When the pointer leaves the tooltip it can hover another point or group. If that does not happen, - // the on-hover-lost handler is called after a short delay. + // When the pointer leaves the tooltip, we immediately check if any point or group is still hovered. + // If not, we fire onHoverLost immediately to prevent the tooltip from staying visible when the mouse + // exits the chart area through the tooltip (e.g., moving left). public onMouseLeaveTooltip = () => { this.tooltipHovered = false; - this.clearHover(); + this.hoverLostCall.cancelPrevious(); + if (!this.hoveredPoint && !this.hoveredGroup) { + this.handlers.onHoverLost(); + this.applyCursorStyle(); + } }; // The mouse-move handler takes all move events inside the chart, and its purpose is to capture hover for groups @@ -123,11 +128,16 @@ export class ChartExtraPointer { } }; - // This event is triggered when the pointer leaves the chart area. Here, it is technically not necessary to add - // a delay before calling the on-hover-lost handler, but it is done for consistency in the UX. + // This event is triggered when the pointer leaves the chart container entirely. + // We immediately clear all hover state since the cursor has definitively left the chart. private onChartMouseout = () => { + this.hoveredPoint = null; this.hoveredGroup = null; - this.clearHover(); + this.hoverLostCall.cancelPrevious(); + if (!this.tooltipHovered) { + this.handlers.onHoverLost(); + this.applyCursorStyle(); + } }; // This event is triggered by Highcharts when there is a click inside the chart plot. It might or might not include From 230627e0c1a350eb2914f66f7e9518fdf443879d Mon Sep 17 00:00:00 2001 From: Philipp Schneider Date: Mon, 9 Mar 2026 10:06:03 +0100 Subject: [PATCH 4/8] fix line chart --- src/core/chart-api/chart-extra-pointer.tsx | 8 ++++++++ src/core/chart-api/index.tsx | 3 +++ 2 files changed, 11 insertions(+) diff --git a/src/core/chart-api/chart-extra-pointer.tsx b/src/core/chart-api/chart-extra-pointer.tsx index f0da44cb..2d951288 100644 --- a/src/core/chart-api/chart-extra-pointer.tsx +++ b/src/core/chart-api/chart-extra-pointer.tsx @@ -65,6 +65,14 @@ export class ChartExtraPointer { this.hoveredGroup = null; }; + // Reset the tooltip hovered state. This is called when the tooltip is programmatically hidden + // (e.g. via clearHighlightActions) to prevent tooltipHovered from getting stuck as true. + // This can happen when the tooltip React component unmounts before the mouseleave event fires, + // leaving tooltipHovered=true and causing subsequent onChartMouseout calls to skip onHoverLost. + public resetTooltipHovered = () => { + this.tooltipHovered = false; + }; + // When the pointer leaves the tooltip, we immediately check if any point or group is still hovered. // If not, we fire onHoverLost immediately to prevent the tooltip from staying visible when the mouse // exits the chart area through the tooltip (e.g., moving left). diff --git a/src/core/chart-api/index.tsx b/src/core/chart-api/index.tsx index cad956f2..3e96e682 100644 --- a/src/core/chart-api/index.tsx +++ b/src/core/chart-api/index.tsx @@ -435,6 +435,9 @@ export class ChartAPI { // Update tooltip and legend state. this.chartExtraTooltip.hideTooltip(); + // Reset the tooltipHovered flag to prevent it from getting stuck as true when the tooltip + // React component unmounts before the mouseleave event fires (e.g. when exiting to the left). + this.chartExtraPointer.resetTooltipHovered(); this.chartExtraLegend.onClearHighlight(); // Notify the consumer that a clear-highlight action was made. From 3274790f310840932831b0a39b02d9c918ce3a95 Mon Sep 17 00:00:00 2001 From: Philipp Schneider Date: Tue, 10 Mar 2026 13:24:07 +0100 Subject: [PATCH 5/8] fix tooltip hover issue --- src/core/chart-api/index.tsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/core/chart-api/index.tsx b/src/core/chart-api/index.tsx index 3e96e682..1c71d90f 100644 --- a/src/core/chart-api/index.tsx +++ b/src/core/chart-api/index.tsx @@ -169,7 +169,16 @@ export class ChartAPI { public renderMarker = this.chartExtraLegend.renderMarker.bind(this.chartExtraLegend); // Callbacks assigned to the tooltip. - public onMouseEnterTooltip = this.chartExtraPointer.onMouseEnterTooltip.bind(this.chartExtraPointer); + // We guard onMouseEnterTooltip with a tooltip visibility check to prevent tooltipHovered from + // getting stuck as true. This can happen when the mouse enters the tooltip DOM element between + // the programmatic hide (reactive state set to visible:false) and the React unmount of the tooltip + // component. Without this guard, onMouseLeaveTooltip never fires (component unmounted), leaving + // tooltipHovered=true and blocking future tooltip dismissals. + public onMouseEnterTooltip = () => { + if (this.chartExtraTooltip.get().visible) { + this.chartExtraPointer.onMouseEnterTooltip(); + } + }; public onMouseLeaveTooltip = this.chartExtraPointer.onMouseLeaveTooltip.bind(this.chartExtraPointer); public onDismissTooltip = (outsideClick?: boolean) => { const { pinned, point, group } = this.chartExtraTooltip.get(); From 38f07cd17f9c6771f841a8d56f562d33f735834f Mon Sep 17 00:00:00 2001 From: Philipp Schneider Date: Wed, 11 Mar 2026 12:02:07 +0100 Subject: [PATCH 6/8] fix tooltip --- src/core/chart-api/chart-extra-pointer.tsx | 23 ++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/core/chart-api/chart-extra-pointer.tsx b/src/core/chart-api/chart-extra-pointer.tsx index 2d951288..0dd0df11 100644 --- a/src/core/chart-api/chart-extra-pointer.tsx +++ b/src/core/chart-api/chart-extra-pointer.tsx @@ -132,6 +132,17 @@ export class ChartExtraPointer { if (!this.tooltipHovered) { this.handlers.onHoverLost(); this.applyCursorStyle(); + } else { + // Safety net: same as in onChartMouseout — if the mouse moves outside the plot area + // while tooltipHovered is true, schedule a deferred check to break the deadlock in case + // onMouseLeaveTooltip never fires (e.g. tooltip unmounts before mouseleave propagates). + this.hoverLostCall.call(() => { + if (this.tooltipHovered && !this.hoveredPoint && !this.hoveredGroup) { + this.tooltipHovered = false; + this.handlers.onHoverLost(); + this.applyCursorStyle(); + } + }, HOVER_LOST_DELAY); } } }; @@ -145,6 +156,18 @@ export class ChartExtraPointer { if (!this.tooltipHovered) { this.handlers.onHoverLost(); this.applyCursorStyle(); + } else { + // Safety net: When the mouse exits the chart while tooltipHovered is true, + // schedule a deferred check. Normally, onMouseLeaveTooltip() will fire and + // handle cleanup. But if it doesn't (browser quirk, React re-render race, + // tooltip at viewport edge), this ensures the tooltip is eventually dismissed. + this.hoverLostCall.call(() => { + if (this.tooltipHovered && !this.hoveredPoint && !this.hoveredGroup) { + this.tooltipHovered = false; + this.handlers.onHoverLost(); + this.applyCursorStyle(); + } + }, HOVER_LOST_DELAY); } }; From ace49e74d9c40011888f8643d54e81204411635f Mon Sep 17 00:00:00 2001 From: Philipp Schneider Date: Wed, 11 Mar 2026 16:01:27 +0100 Subject: [PATCH 7/8] fix --- src/core/chart-api/chart-extra-pointer.tsx | 31 ++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/core/chart-api/chart-extra-pointer.tsx b/src/core/chart-api/chart-extra-pointer.tsx index 0dd0df11..bd8cd7b1 100644 --- a/src/core/chart-api/chart-extra-pointer.tsx +++ b/src/core/chart-api/chart-extra-pointer.tsx @@ -25,6 +25,12 @@ export class ChartExtraPointer { private hoveredPoint: null | Highcharts.Point = null; private hoveredGroup: null | Highcharts.Point[] = null; private tooltipHovered = false; + // When the mouse exits the tooltip through its arrow/padding area back into the chart's plot area, + // the mousemove handler immediately finds the nearest group (the same one the tooltip was showing for) + // and re-triggers the tooltip. This creates an infinite show/hide loop that makes the tooltip appear stuck. + // This flag suppresses re-hovering for a short window after leaving the tooltip to break the cycle. + private recentlyLeftTooltip = false; + private recentlyLeftTooltipTimer: ReturnType | null = null; private hoverLostCall = new DebouncedCall(); constructor(context: ChartExtraContext, handlers: ChartExtraPointerHandlers) { @@ -40,6 +46,10 @@ export class ChartExtraPointer { public onChartDestroy = () => { this.context.chartOrNull?.container?.removeEventListener("mousemove", this.onChartMousemove); this.context.chartOrNull?.container?.removeEventListener("mouseleave", this.onChartMouseout); + if (this.recentlyLeftTooltipTimer !== null) { + clearTimeout(this.recentlyLeftTooltipTimer); + this.recentlyLeftTooltipTimer = null; + } }; // This event is triggered by Highcharts when the cursor is over a Highcharts point. We leave this to @@ -80,6 +90,10 @@ export class ChartExtraPointer { this.tooltipHovered = false; this.hoverLostCall.cancelPrevious(); if (!this.hoveredPoint && !this.hoveredGroup) { + // Suppress re-hovering briefly to prevent an infinite show/hide loop when the mouse exits + // the tooltip through its arrow/padding area back into the chart's plot area. Without this, + // onChartMousemove immediately re-matches the same group and re-shows the tooltip. + this.setRecentlyLeftTooltip(); this.handlers.onHoverLost(); this.applyCursorStyle(); } @@ -201,6 +215,9 @@ export class ChartExtraPointer { }; private setHoveredPoint = (point: Highcharts.Point) => { + if (this.recentlyLeftTooltip) { + return; + } if (isPointVisible(point)) { this.hoveredPoint = point; this.hoveredGroup = null; @@ -210,6 +227,9 @@ export class ChartExtraPointer { }; private setHoveredGroup = (group: Highcharts.Point[]) => { + if (this.recentlyLeftTooltip) { + return; + } if (!this.hoveredPoint || !isPointVisible(this.hoveredPoint)) { const availablePoints = group.filter(isPointVisible); this.hoveredPoint = null; @@ -219,6 +239,17 @@ export class ChartExtraPointer { } }; + private setRecentlyLeftTooltip = () => { + this.recentlyLeftTooltip = true; + if (this.recentlyLeftTooltipTimer !== null) { + clearTimeout(this.recentlyLeftTooltipTimer); + } + this.recentlyLeftTooltipTimer = setTimeout(() => { + this.recentlyLeftTooltip = false; + this.recentlyLeftTooltipTimer = null; + }, HOVER_LOST_DELAY); + }; + // The function calls the on-hover-lost handler in a short delay to give time for the hover to // transition from one target to another. Before calling the handler we check if no target // (point, group, or tooltip) is hovered. From 484ec31f38cc49d8ec52767b85dfcaba05bdaf5b Mon Sep 17 00:00:00 2001 From: Philipp Schneider Date: Thu, 12 Mar 2026 11:17:35 +0100 Subject: [PATCH 8/8] fix --- src/core/chart-api/chart-extra-pointer.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/core/chart-api/chart-extra-pointer.tsx b/src/core/chart-api/chart-extra-pointer.tsx index bd8cd7b1..c24ef8b8 100644 --- a/src/core/chart-api/chart-extra-pointer.tsx +++ b/src/core/chart-api/chart-extra-pointer.tsx @@ -30,6 +30,7 @@ export class ChartExtraPointer { // and re-triggers the tooltip. This creates an infinite show/hide loop that makes the tooltip appear stuck. // This flag suppresses re-hovering for a short window after leaving the tooltip to break the cycle. private recentlyLeftTooltip = false; + private recentlyLeftTooltipX: number | null = null; private recentlyLeftTooltipTimer: ReturnType | null = null; private hoverLostCall = new DebouncedCall(); @@ -70,6 +71,9 @@ export class ChartExtraPointer { // Wo do, hover, clear the point and group hover state so that if the pointer leaves chart from the tooltip, // the on-hover-lost handler is still called. public onMouseEnterTooltip = () => { + // Save the X of the currently hovered group/point before clearing, so that + // setRecentlyLeftTooltip can scope the re-hover suppression to this X only. + this.recentlyLeftTooltipX = this.hoveredGroup?.[0]?.x ?? this.hoveredPoint?.x ?? null; this.tooltipHovered = true; this.hoveredPoint = null; this.hoveredGroup = null; @@ -215,7 +219,9 @@ export class ChartExtraPointer { }; private setHoveredPoint = (point: Highcharts.Point) => { - if (this.recentlyLeftTooltip) { + // Only suppress re-hover for the same X position that was just dismissed, + // allowing adjacent groups to still show their tooltip without flickering. + if (this.recentlyLeftTooltip && point.x === this.recentlyLeftTooltipX) { return; } if (isPointVisible(point)) { @@ -227,7 +233,9 @@ export class ChartExtraPointer { }; private setHoveredGroup = (group: Highcharts.Point[]) => { - if (this.recentlyLeftTooltip) { + // Only suppress re-hover for the same X position that was just dismissed, + // allowing adjacent groups to still show their tooltip without flickering. + if (this.recentlyLeftTooltip && group[0]?.x === this.recentlyLeftTooltipX) { return; } if (!this.hoveredPoint || !isPointVisible(this.hoveredPoint)) {