recharts
recharts copied to clipboard
Render Tooltip to React Portal to Prevent Clipping
- [x] I have searched the issues of this repository and believe that this is not a duplicate.
What problem does this feature solve?
Currently, if there are enough data series shown in the Tooltip to result in a Tooltip taller than the height of the chart and it's "chrome" (axes, legend, etc.), the tooltip is often clipped by the DOM element that contains the chart (note: it is not the chart itself that is clipping the Tooltip, but rarely if ever will a chart be the only element on a page by itself). By using a React Portal (https://reactjs.org/docs/portals.html), the Tooltip can be rendered independently of the chart and other elements in the DOM hierarchy, thus preventing the clipping.
What does the proposed API look like?
No change to the API would be necessary. This should be a transparent, non-breaking change.
For people who are looking for a baseline implementation (with react-popper
):
import type { VirtualElement as IVirtualElement } from "@popperjs/core";
import { ReactNode, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { usePopper } from "react-popper";
import { useEvent } from "react-use";
import useLazyRef from "~/utils/useLazyRef";
export interface PopperPortalProps {
active?: boolean;
children: ReactNode;
}
export default function PopperPortal({
active = true,
children,
}: PopperPortalProps) {
const [portalElement, setPortalElement] = useState<HTMLDivElement>();
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>();
const virtualElementRef = useLazyRef(() => new VirtualElement());
const { styles, attributes, update } = usePopper(
virtualElementRef.current,
popperElement,
POPPER_OPTIONS
);
useEffect(() => {
const el = document.createElement("div");
document.body.appendChild(el);
setPortalElement(el);
return () => el.remove();
}, []);
useEvent("mousemove", ({ clientX: x, clientY: y }) => {
virtualElementRef.current.update(x, y);
if (!active) return;
update?.();
});
useEffect(() => {
if (!active) return;
update?.();
}, [active, update]);
if (!portalElement) return null;
return createPortal(
<div
ref={setPopperElement}
{...attributes.popper}
style={{
...styles.popper,
zIndex: 1000,
display: active ? "block" : "none",
}}
>
{children}
</div>,
portalElement
);
}
class VirtualElement implements IVirtualElement {
private rect = {
width: 0,
height: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
x: 0,
y: 0,
toJSON() {
return this;
},
};
update(x: number, y: number) {
this.rect.y = y;
this.rect.top = y;
this.rect.bottom = y;
this.rect.x = x;
this.rect.left = x;
this.rect.right = x;
}
getBoundingClientRect(): DOMRect {
return this.rect;
}
}
const POPPER_OPTIONS: Parameters<typeof usePopper>[2] = {
placement: "right-start",
modifiers: [
{
name: "offset",
options: {
offset: [8, 8],
},
},
],
};
Usage
function MyComponent() {
return (
<BarChart>
<Tooltip content={<TooltipContent />} />
</BarChart>
}
}
function TooltipContent({ active }: TooltipProps) {
return (
<PopperPortal active={active}>
CUSTOM TOOLTIP RENDERER
</PopperPortal>
);
}
As an alternative, <Tooltip />
could add a portal
prop taking a DOM element, and render its contents inside that element if it is given.
We need Portals! Please support it!
Hello, Has this feature request development been shipped ? Thanks in advance for your answer.
No, no work on portals yet. Refactoring needs to be done first in order to make it viable
ok thanks for the answer, then external custom Tooltip it is ;-)
@CyrilQuandalle could you share how you've solved it? (your custom implementation)
I'm facing this issue as well as another issue where the tooltip hides the entire chart. It happens if it's big enough, the chart is small enough and is located near the edge of the viewport. I would expect it to go above the chart in that case and not hide it.
All in all, seems like the tooltip's implementation is very lacking.
This should be available in 3.0 release