Compare commits

...

2 Commits

Author SHA1 Message Date
Abhi kumar
129d18a1b7 fix: added fix for tooltip not rendering in fullscreen mode (#10504)
Some checks are pending
build-staging / staging (push) Blocked by required conditions
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
Release Drafter / update_release_draft (push) Waiting to run
* fix: added fix for tooltip not rendering in fullscreen mode

* chore: added test for tooltip parent behaviour
2026-03-05 20:30:05 +05:30
Abhi kumar
48ccdfcf64 enh: updated tooltip pinning structure to be used across different charts (#10459)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: made baseconfigbuilder generic to be used across different charts

* chore: updated baseconfigbuilder test

* chore: updated timezone types

* chore: fixed tsc + test

* chore: fixed tsc + test

* chore: fixed tsc + test

* feat: added the base changes for pinnedtooltip element

* chore: fix tsc + test

* chore: fixed tooltipplugin test

* chore: restructured the code

* chore: restrucuted tooltip schema and resolved pr comments

* chore: fixed cursor comments

* chore: fixed cursor comments

* chore: fixed cursor comments
2026-03-05 16:12:34 +05:30
12 changed files with 248 additions and 33 deletions

View File

@@ -10,7 +10,15 @@ import { useBarChartStacking } from '../../hooks/useBarChartStacking';
import { BarChartProps } from '../types';
export default function BarChart(props: BarChartProps): JSX.Element {
const { children, isStackedBarChart, config, data, ...rest } = props;
const {
children,
isStackedBarChart,
customTooltip,
config,
data,
pinnedTooltipElement,
...rest
} = props;
const chartData = useBarChartStacking({
data,
@@ -20,6 +28,9 @@ export default function BarChart(props: BarChartProps): JSX.Element {
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
if (customTooltip) {
return customTooltip(props);
}
const tooltipProps: BarTooltipProps = {
...props,
timezone: rest.timezone,
@@ -29,7 +40,13 @@ export default function BarChart(props: BarChartProps): JSX.Element {
};
return <BarChartTooltip {...tooltipProps} />;
},
[rest.timezone, rest.yAxisUnit, rest.decimalPrecision, isStackedBarChart],
[
customTooltip,
rest.timezone,
rest.yAxisUnit,
rest.decimalPrecision,
isStackedBarChart,
],
);
return (
@@ -37,7 +54,8 @@ export default function BarChart(props: BarChartProps): JSX.Element {
{...rest}
config={config}
data={chartData}
renderTooltip={renderTooltip}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
{children}
</ChartWrapper>

View File

@@ -30,7 +30,8 @@ export default function ChartWrapper({
onDestroy = noop,
children,
layoutChildren,
renderTooltip,
customTooltip,
pinnedTooltipElement,
'data-testid': testId,
}: ChartProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
@@ -53,12 +54,12 @@ export default function ChartWrapper({
const renderTooltipCallback = useCallback(
(args: TooltipRenderArgs): React.ReactNode => {
if (renderTooltip) {
return renderTooltip(args);
if (customTooltip) {
return customTooltip(args);
}
return null;
},
[renderTooltip],
[customTooltip],
);
return (
@@ -99,6 +100,7 @@ export default function ChartWrapper({
)}
syncKey={syncKey}
render={renderTooltipCallback}
pinnedTooltipElement={pinnedTooltipElement}
/>
)}
</UPlotChart>

View File

@@ -11,15 +11,16 @@ import { HistogramChartProps } from '../types';
export default function Histogram(props: HistogramChartProps): JSX.Element {
const {
children,
renderTooltip: customRenderTooltip,
customTooltip,
isQueriesMerged,
pinnedTooltipElement,
...rest
} = props;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
if (customRenderTooltip) {
return customRenderTooltip(props);
if (customTooltip) {
return customTooltip(props);
}
const tooltipProps: HistogramTooltipProps = {
...props,
@@ -29,14 +30,15 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
};
return <HistogramTooltip {...tooltipProps} />;
},
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
);
return (
<ChartWrapper
showLegend={!isQueriesMerged}
{...rest}
renderTooltip={renderTooltip}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
{children}
</ChartWrapper>

View File

@@ -9,12 +9,12 @@ import {
import { TimeSeriesChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, renderTooltip: customRenderTooltip, ...rest } = props;
const { children, customTooltip, pinnedTooltipElement, ...rest } = props;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
if (customRenderTooltip) {
return customRenderTooltip(props);
if (customTooltip) {
return customTooltip(props);
}
const tooltipProps: TimeSeriesTooltipProps = {
...props,
@@ -24,11 +24,15 @@ export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
};
return <TimeSeriesTooltip {...tooltipProps} />;
},
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
);
return (
<ChartWrapper {...rest} renderTooltip={renderTooltip}>
<ChartWrapper
{...rest}
customTooltip={renderTooltip}
pinnedTooltipElement={pinnedTooltipElement}
>
{children}
</ChartWrapper>
);

View File

@@ -2,7 +2,10 @@ import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import {
DashboardCursorSync,
TooltipClickData,
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
interface BaseChartProps {
width: number;
@@ -13,7 +16,8 @@ interface BaseChartProps {
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
renderTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
}
interface UPlotBasedChartProps {

View File

@@ -66,7 +66,7 @@ export const prepareUPlotConfig = ({
widget: Widgets;
isDarkMode: boolean;
currentQuery: Query;
onClick: OnClickPluginOpts['onClick'];
onClick?: OnClickPluginOpts['onClick'];
onDragSelect: (startTime: number, endTime: number) => void;
apiResponse: MetricRangePayloadProps;
timezone: Timezone;

View File

@@ -92,6 +92,7 @@ function renderTooltip(props: Partial<TooltipTestProps> = {}): RenderResult {
isPinned: false,
dismiss: jest.fn(),
viaSync: false,
clickData: null,
} as TooltipTestProps;
return render(

View File

@@ -1,6 +1,7 @@
import { useLayoutEffect, useRef, useState } from 'react';
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import cx from 'classnames';
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import uPlot from 'uplot';
import {
@@ -15,6 +16,7 @@ import {
} from './tooltipController';
import {
DashboardCursorSync,
TooltipClickData,
TooltipControllerContext,
TooltipControllerState,
TooltipLayoutInfo,
@@ -38,15 +40,18 @@ export default function TooltipPlugin({
maxHeight = 400,
syncMode = DashboardCursorSync.None,
syncKey = '_tooltip_sync_global_',
pinnedTooltipElement,
canPinTooltip = false,
}: TooltipPluginProps): JSX.Element | null {
const containerRef = useRef<HTMLDivElement>(null);
const portalRoot = useRef<HTMLElement>(document.body);
const rafId = useRef<number | null>(null);
const dismissTimeoutId = useRef<ReturnType<typeof setTimeout> | null>(null);
const layoutRef = useRef<TooltipLayoutInfo>();
const renderRef = useRef(render);
renderRef.current = render;
const [portalRoot, setPortalRoot] = useState<HTMLElement>(
(document.fullscreenElement as HTMLElement) ?? document.body,
);
// React-managed snapshot of what should be rendered. The controller
// owns the interaction state and calls `updateState` when a visible
@@ -73,11 +78,6 @@ export default function TooltipPlugin({
layoutRef.current?.observer.disconnect();
layoutRef.current = createLayoutObserver(layoutRef);
/**
* Plot lifecycle and GC: viewState uses hasPlot (boolean), not the plot
* reference; clearPlotReferences runs in cleanup so
* detached canvases can be garbage collected.
*/
// Controller holds the mutable interaction state for this tooltip
// instance. It is intentionally *not* React state so uPlot hooks
// and DOM listeners can update it freely without triggering a
@@ -85,7 +85,9 @@ export default function TooltipPlugin({
const controller: TooltipControllerState = createInitialControllerState();
/**
* Clear plot references so detached canvases can be garbage collected.
* Plot lifecycle and GC: viewState uses hasPlot (boolean), not the plot
* reference; clearPlotReferences runs in cleanup so
* detached canvases can be garbage collected.
*/
const clearPlotReferences = (): void => {
controller.plot = null;
@@ -157,6 +159,7 @@ export default function TooltipPlugin({
const isPinnedBeforeDismiss = controller.pinned;
controller.pinned = false;
controller.hoverActive = false;
controller.clickData = null;
const plot = getPlot(controller);
if (plot) {
plot.setCursor({ left: -10, top: -10 });
@@ -196,6 +199,7 @@ export default function TooltipPlugin({
style: controller.style,
isPinned: controller.pinned,
isHovering: controller.hoverActive,
clickData: controller.clickData,
contents: createTooltipContents(),
dismiss: dismissTooltip,
});
@@ -268,6 +272,37 @@ export default function TooltipPlugin({
!controller.pinned &&
controller.focusedSeriesIndex != null
) {
const xValue = plot.posToVal(event.offsetX, 'x');
const yValue = plot.posToVal(event.offsetY, 'y');
const focusedSeries = getFocusedSeriesAtPosition(event, plot);
let clickedDataTimestamp = xValue;
if (focusedSeries) {
const dataIndex = plot.posToIdx(event.offsetX);
const xSeriesData = plot.data[0];
if (
xSeriesData &&
dataIndex >= 0 &&
dataIndex < xSeriesData.length &&
xSeriesData[dataIndex] !== undefined
) {
clickedDataTimestamp = xSeriesData[dataIndex];
}
}
const clickData: TooltipClickData = {
xValue,
yValue,
focusedSeries,
clickedDataTimestamp,
mouseX: event.offsetX,
mouseY: event.offsetY,
absoluteMouseX: event.clientX,
absoluteMouseY: event.clientY,
};
controller.clickData = clickData;
setTimeout(() => {
controller.pinned = true;
scheduleRender(true);
@@ -356,6 +391,19 @@ export default function TooltipPlugin({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config]);
const resolvePortalRoot = useCallback((): void => {
setPortalRoot((document.fullscreenElement as HTMLElement) ?? document.body);
}, []);
useLayoutEffect((): (() => void) => {
resolvePortalRoot();
document.addEventListener('fullscreenchange', resolvePortalRoot);
return (): void => {
document.removeEventListener('fullscreenchange', resolvePortalRoot);
};
}, [resolvePortalRoot]);
useLayoutEffect((): void => {
if (!hasPlot || !layoutRef.current) {
return;
@@ -374,6 +422,25 @@ export default function TooltipPlugin({
}
}, [isHovering, hasPlot]);
const tooltipBody = useMemo(() => {
if (isPinned && pinnedTooltipElement != null && viewState.clickData != null) {
return pinnedTooltipElement(viewState.clickData);
}
if (isHovering) {
return contents;
}
return null;
}, [
isPinned,
pinnedTooltipElement,
viewState.clickData,
isHovering,
contents,
]);
const isTooltipVisible = isHovering || tooltipBody != null;
if (!hasPlot) {
return null;
}
@@ -382,7 +449,7 @@ export default function TooltipPlugin({
<div
className={cx('tooltip-plugin-container', {
pinned: isPinned,
visible: isHovering,
visible: isTooltipVisible,
})}
style={{
...style,
@@ -391,12 +458,12 @@ export default function TooltipPlugin({
}}
aria-live="polite"
aria-atomic="true"
aria-hidden={!isHovering}
aria-hidden={!isTooltipVisible}
ref={containerRef}
data-testid="tooltip-plugin-container"
>
{isHovering ? contents : null}
{tooltipBody}
</div>,
portalRoot.current,
portalRoot,
);
}

View File

@@ -21,6 +21,7 @@ export function createInitialControllerState(): TooltipControllerState {
hoverActive: false,
isAnySeriesActive: false,
pinned: false,
clickData: null,
style: { transform: '', pointerEvents: 'none' },
horizontalOffset: 0,
verticalOffset: 0,

View File

@@ -24,6 +24,7 @@ export interface TooltipViewState {
isHovering: boolean;
isPinned: boolean;
dismiss: () => void;
clickData: TooltipClickData | null;
contents?: ReactNode;
}
@@ -39,10 +40,27 @@ export interface TooltipPluginProps {
syncMode?: DashboardCursorSync;
syncKey?: string;
render: (args: TooltipRenderArgs) => ReactNode;
pinnedTooltipElement?: (clickData: TooltipClickData) => ReactNode;
maxWidth?: number;
maxHeight?: number;
}
export interface TooltipClickData {
xValue: number;
yValue: number;
focusedSeries: {
seriesIndex: number;
seriesName: string;
value: number;
color: string;
} | null;
clickedDataTimestamp: number;
mouseX: number;
mouseY: number;
absoluteMouseX: number;
absoluteMouseY: number;
}
/**
* Mutable, non-React state that drives tooltip behaviour:
* - whether the tooltip is active / pinned
@@ -59,6 +77,7 @@ export interface TooltipControllerState {
hoverActive: boolean;
isAnySeriesActive: boolean;
pinned: boolean;
clickData: TooltipClickData | null;
style: TooltipViewState['style'];
horizontalOffset: number;
verticalOffset: number;

View File

@@ -125,6 +125,7 @@ export function createInitialViewState(): TooltipViewState {
contents: null,
hasPlot: false,
dismiss: (): void => {},
clickData: null,
};
}

View File

@@ -9,6 +9,12 @@ import { UPlotConfigBuilder } from '../../config/UPlotConfigBuilder';
import TooltipPlugin from '../TooltipPlugin/TooltipPlugin';
import { DashboardCursorSync } from '../TooltipPlugin/types';
// Avoid depending on the full uPlot + onClickPlugin behaviour in these tests.
// We only care that pinning logic runs without throwing, not which series is focused.
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => ({
getFocusedSeriesAtPosition: jest.fn(() => null),
}));
// ---------------------------------------------------------------------------
// Mock helpers
// ---------------------------------------------------------------------------
@@ -55,11 +61,21 @@ function createFakePlot(): {
over: HTMLDivElement;
setCursor: jest.Mock<void, [uPlot.Cursor]>;
cursor: { event: Record<string, unknown> };
posToVal: jest.Mock<number, [value: number]>;
posToIdx: jest.Mock<number, []>;
data: [number[], number[]];
} {
const over = document.createElement('div');
// Provide the minimal uPlot surface used by TooltipPlugin's pin logic.
return {
over: document.createElement('div'),
over,
setCursor: jest.fn(),
cursor: { event: {} },
// In real uPlot these map overlay coordinates to data-space values.
posToVal: jest.fn((value: number) => value),
posToIdx: jest.fn(() => 0),
data: [[0], [0]],
};
}
@@ -172,6 +188,58 @@ describe('TooltipPlugin', () => {
expect(container).not.toBeNull();
expect(container.parentElement).toBe(document.body);
});
it('moves tooltip portal root to fullscreen element and back on exit', async () => {
const config = createConfigMock();
let mockedFullscreenElement: Element | null = null;
const originalFullscreenElementDescriptor = Object.getOwnPropertyDescriptor(
Document.prototype,
'fullscreenElement',
);
Object.defineProperty(Document.prototype, 'fullscreenElement', {
configurable: true,
get: () => mockedFullscreenElement,
});
renderAndActivateHover(config);
const container = document.querySelector(
'.tooltip-plugin-container',
) as HTMLElement;
expect(container.parentElement).toBe(document.body);
const fullscreenRoot = document.createElement('div');
document.body.appendChild(fullscreenRoot);
act(() => {
mockedFullscreenElement = fullscreenRoot;
document.dispatchEvent(new Event('fullscreenchange'));
});
await waitFor(() => {
const updatedContainer = screen.getByTestId('tooltip-plugin-container');
expect(updatedContainer.parentElement).toBe(fullscreenRoot);
});
act(() => {
mockedFullscreenElement = null;
document.dispatchEvent(new Event('fullscreenchange'));
});
await waitFor(() => {
const updatedContainer = screen.getByTestId('tooltip-plugin-container');
expect(updatedContainer.parentElement).toBe(document.body);
});
if (originalFullscreenElementDescriptor) {
Object.defineProperty(
Document.prototype,
'fullscreenElement',
originalFullscreenElementDescriptor,
);
}
fullscreenRoot.remove();
});
});
// ---- Pin behaviour ----------------------------------------------------------
@@ -198,6 +266,34 @@ describe('TooltipPlugin', () => {
});
});
it('renders pinnedTooltipElement after pinning and hides hover content', async () => {
const config = createConfigMock();
const pinnedTooltipElement = jest.fn(() =>
React.createElement('div', null, 'pinned-tooltip'),
);
const fakePlot = renderAndActivateHover(
config,
() => React.createElement('div', null, 'hover-tooltip'),
{
canPinTooltip: true,
pinnedTooltipElement,
},
);
expect(screen.getByText('hover-tooltip')).toBeInTheDocument();
act(() => {
fakePlot.over.dispatchEvent(new MouseEvent('click', { bubbles: true }));
});
await waitFor(() => {
expect(pinnedTooltipElement).toHaveBeenCalled();
expect(screen.getByText('pinned-tooltip')).toBeInTheDocument();
expect(screen.queryByText('hover-tooltip')).not.toBeInTheDocument();
});
});
it('dismisses a pinned tooltip via the dismiss callback', async () => {
const config = createConfigMock();