Anchored tooltip (#15568)

Issue - 
- tooltip gets too long when groupby has too much data points
- we need max height on tooltips
- we need to be able to scroll inside the tooltip
- not possible if the tooltip is following the cursor (which library
enforces)
- its not easy to customize Nivo's own tooltip to make it work how we
want it

what we want - 
- let the tooltip anchor to the bar in such a way -- that the top of the
bar aligns with the vertical middle of the tooltip
- add max height to the tooltip

what I did - 
- use floating portal from floating-ui/react
- get the hover datum (ie hovered bars) dimensions on mouse enter to
render the tooltip
- anchor the tooltip at the calculated position
- floating-ui handles the basic things like flipping/offset/shift
- clear the states as required

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
nitin
2025-11-11 21:17:41 +05:30
committed by GitHub
parent 652c8673e0
commit 561297436e
44 changed files with 1118 additions and 448 deletions
@@ -0,0 +1,15 @@
import { type VirtualElement } from '@floating-ui/react';
type ChartCoordinates = {
left: number;
top: number;
};
export const createVirtualElementFromChartCoordinates = (
chartCoordinates: ChartCoordinates,
): VirtualElement => {
const { left, top } = chartCoordinates;
return {
getBoundingClientRect: () => new DOMRect(left, top, 1, 1),
};
};
@@ -0,0 +1,16 @@
import { type VirtualElement } from '@floating-ui/react';
export const createVirtualElementFromContainerOffset = (
container: Element,
offsetLeft: number,
offsetTop: number,
): VirtualElement => {
return {
getBoundingClientRect: () => {
const rect = container.getBoundingClientRect();
const left = rect.left + offsetLeft;
const top = rect.top + offsetTop;
return new DOMRect(left, top, 1, 1);
},
};
};
@@ -0,0 +1,23 @@
import { type VirtualElement } from '@floating-ui/react';
import { isDefined } from 'twenty-shared/utils';
export const createVirtualElementFromSVGElement = (
svgElement: Element,
): VirtualElement | null => {
const svgContainer = svgElement.closest('svg');
if (!isDefined(svgContainer)) {
return null;
}
return {
getBoundingClientRect: () => {
const elementRect = svgElement.getBoundingClientRect();
return new DOMRect(
elementRect.left,
elementRect.top,
elementRect.width,
1,
);
},
};
};
@@ -0,0 +1,18 @@
import { type VirtualElement } from '@floating-ui/react';
import { isDefined } from 'twenty-shared/utils';
export const getTooltipReferenceFromBarChartElementAnchor = (
anchorElement: Element,
containerId: string,
): {
reference: Element | VirtualElement;
boundary: Element;
} => {
const containerElement = document.getElementById(containerId);
if (!isDefined(containerElement)) {
throw new Error(`Bar chart container not found: ${containerId}`);
}
return { reference: anchorElement, boundary: containerElement };
};
@@ -0,0 +1,27 @@
import { createVirtualElementFromContainerOffset } from '@/page-layout/widgets/graph/utils/createVirtualElementFromContainerOffset';
import { type VirtualElement } from '@floating-ui/react';
import { isDefined } from 'twenty-shared/utils';
export const getTooltipReferenceFromLineChartPointAnchor = (
containerId: string,
offsetLeft: number,
offsetTop: number,
): {
reference: VirtualElement;
boundary: Element;
} => {
const containerElement = document.getElementById(containerId);
if (!isDefined(containerElement)) {
throw new Error(`Chart container not found: ${containerId}`);
}
return {
reference: createVirtualElementFromContainerOffset(
containerElement,
offsetLeft,
offsetTop,
),
boundary: containerElement,
};
};