Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
46 changes: 45 additions & 1 deletion src/core/__tests__/chart-core-tooltip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -819,6 +821,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({
Expand Down
106 changes: 96 additions & 10 deletions src/core/chart-api/chart-extra-pointer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ 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 recentlyLeftTooltipX: number | null = null;
private recentlyLeftTooltipTimer: ReturnType<typeof setTimeout> | null = null;
private hoverLostCall = new DebouncedCall();

constructor(context: ChartExtraContext, handlers: ChartExtraPointerHandlers) {
Expand All @@ -34,12 +41,16 @@ 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);
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
Expand All @@ -60,16 +71,36 @@ 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;
};

// 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.
// 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).
public onMouseLeaveTooltip = () => {
this.tooltipHovered = false;
this.clearHover();
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();
}
};

// The mouse-move handler takes all move events inside the chart, and its purpose is to capture hover for groups
Expand Down Expand Up @@ -110,18 +141,52 @@ 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();
} 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);
}
}
};

// 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();
} 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);
}
};

// This event is triggered by Highcharts when there is a click inside the chart plot. It might or might not include
Expand Down Expand Up @@ -154,6 +219,11 @@ export class ChartExtraPointer {
};

private setHoveredPoint = (point: Highcharts.Point) => {
// 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)) {
this.hoveredPoint = point;
this.hoveredGroup = null;
Expand All @@ -163,6 +233,11 @@ export class ChartExtraPointer {
};

private setHoveredGroup = (group: Highcharts.Point[]) => {
// 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)) {
const availablePoints = group.filter(isPointVisible);
this.hoveredPoint = null;
Expand All @@ -172,6 +247,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.
Expand Down
14 changes: 13 additions & 1 deletion src/core/chart-api/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -435,6 +444,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.
Expand Down
Loading