Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(tooltip): Enable custom portal container for tooltip #1567

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
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
73 changes: 73 additions & 0 deletions packages/visx-demo/src/sandboxes/visx-tooltip/Example.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState, useCallback } from 'react';
import ReactDOM from 'react-dom';
import {
Tooltip,
TooltipWithBounds,
Expand Down Expand Up @@ -26,13 +27,41 @@ const tooltipStyles = {
padding: 12,
};

type OverlayLayerProps = {
container: HTMLDivElement | null;
text: string;
className?: string;
placeAfterTooltipInDom?: boolean;
};

function OverlayLayer({ className, container, placeAfterTooltipInDom, text }: OverlayLayerProps) {
if (container) {
// Since we re-render the tooltip every time the pointer moves and its DOM node
// is placed at the end of the container, if placeAfterTooltipInDom is true we
// also want to re-render the overlay layer
const key = placeAfterTooltipInDom ? Math.random() : 'overlay-under';
return ReactDOM.createPortal(
<div className={className} key={key}>
{text}
</div>,
container,
);
}
return null;
}

export default function Example({ width, height, showControls = true }: TooltipProps) {
const [tooltipShouldDetectBounds, setTooltipShouldDetectBounds] = useState(true);
const [tooltipShouldUseCustomContainer, setTooltipShouldUseCustomContainer] = useState(true);
const [renderTooltipInPortal, setRenderTooltipInPortal] = useState(false);
const overlayRootRef = React.useRef<HTMLDivElement | null>(null);

const { containerRef, containerBounds, TooltipInPortal } = useTooltipInPortal({
scroll: true,
detectBounds: tooltipShouldDetectBounds,
portalContainer: tooltipShouldUseCustomContainer
? overlayRootRef.current ?? undefined
: undefined,
});

const {
Expand Down Expand Up @@ -81,6 +110,12 @@ export default function Example({ width, height, showControls = true }: TooltipP
style={{ width, height }}
onPointerMove={handlePointerMove}
>
<div className="overlay-root" ref={overlayRootRef} />
<OverlayLayer
className="overlay-layer overlay-under-tooltip"
container={overlayRootRef.current}
text="We want this to appear under the tooltip."
/>
{tooltipOpen ? (
<>
<div
Expand Down Expand Up @@ -115,6 +150,12 @@ export default function Example({ width, height, showControls = true }: TooltipP
) : (
<div className="no-tooltip">Move or touch the canvas to see the tooltip</div>
)}
<OverlayLayer
className="overlay-layer overlay-over-tooltip"
container={overlayRootRef.current}
placeAfterTooltipInDom // Force DOM node to be placed after tooltip for demo purposes
text="We want this to appear over the tooltip."
/>
<div className="z-index-bummer">
I have an annoying z-index. Try&nbsp;
<label>
Expand Down Expand Up @@ -146,6 +187,19 @@ export default function Example({ width, height, showControls = true }: TooltipP
&nbsp;Tooltip with boundary detection
</label>

{renderTooltipInPortal && (
<label>
<input
type="checkbox"
checked={tooltipShouldUseCustomContainer}
onChange={() =>
setTooltipShouldUseCustomContainer(!tooltipShouldUseCustomContainer)
}
/>
&nbsp;Tooltip portal in custom container
</label>
)}

<button onClick={() => hideTooltip()}>Hide tooltip</button>
</div>
)}
Expand Down Expand Up @@ -205,6 +259,25 @@ export default function Example({ width, height, showControls = true }: TooltipP
padding: 16px;
line-height: 1.2em;
}
.overlay-root {
z-index: 3000;
position: relative;
}
.overlay-layer {
position: absolute;
border-radius: 8px;
padding: 8px;
}
.overlay-under-tooltip {
top: 30px;
right: 10px;
background: rgba(52, 235, 180, 0.8);
}
.overlay-over-tooltip {
top: 70px;
right: 30px;
background: rgba(250, 235, 180, 0.8);
}
`}</style>
</>
);
Expand Down
7 changes: 5 additions & 2 deletions packages/visx-tooltip/Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@ object or inject it cleanly using the `polyfill` config option below.

`useTooltipInPortal` is a hook which gives you a `TooltipInPortal` component for rendering `Tooltip`
or `TooltipWithBounds` in a `Portal`, outside of your component DOM tree which can be useful in many
circumstances (see below for more on `Portal`s).
circumstances (see below for more on `Portal`s). A custom portal container can be provided
via the `portalContainer` config option.

##### API

Expand All @@ -128,7 +129,9 @@ type Options = {
scroll?: boolean
/** You can optionally inject a resize-observer polyfill */
polyfill?: { new (cb: ResizeObserverCallback): ResizeObserver }
/** Optional z-index to set on the Portal div */
/** Optional container for the portal. */
portalContainer?: HTMLDivElement;
/** Optional z-index to set on the Portal div (not applicable when a specific portal container is provided) */
zIndex?: number | string;
}

Expand Down
1 change: 1 addition & 0 deletions packages/visx-tooltip/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
},
"homepage": "https://github.com/airbnb/visx#readme",
"dependencies": {
"@react-hook/resize-observer": "^1.2.6",
"@types/react": "*",
"@visx/bounds": "3.0.0",
"classnames": "^2.3.1",
Expand Down
10 changes: 8 additions & 2 deletions packages/visx-tooltip/src/Portal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import React from 'react';
import ReactDOM from 'react-dom';

export type PortalProps = {
/** Optional z-index to set on the Portal. */
/** Optional container for the Portal. */
container?: HTMLDivElement;
/** Optional z-index to set on the Portal (not applicable when a specific portal container is provided). */
zIndex?: number | string;
/** Content to render in the Portal. */
children: NonNullable<React.ReactNode>;
Expand All @@ -13,13 +15,17 @@ export default class Portal extends React.PureComponent<PortalProps> {
private node?: HTMLDivElement;

componentWillUnmount() {
if (this.node && document.body) {
if (this.node && document.body && !this.props.container) {
document.body.removeChild(this.node);
delete this.node;
}
}

render() {
if (!this.node && this.props.container) {
this.node = this.props.container;
}

// SSR check
if (!this.node && typeof document !== 'undefined') {
this.node = document.createElement('div');
Expand Down
64 changes: 55 additions & 9 deletions packages/visx-tooltip/src/hooks/useTooltipInPortal.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import React, { useMemo } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import useMeasure, { RectReadOnly, Options as BaseUseMeasureOptions } from 'react-use-measure';
import useResizeObserver from '@react-hook/resize-observer';

import Portal, { PortalProps } from '../Portal';
import Tooltip, { TooltipProps } from '../tooltips/Tooltip';
import TooltipWithBounds from '../tooltips/TooltipWithBounds';

export type TooltipInPortalProps = TooltipProps &
Pick<UseTooltipPortalOptions, 'detectBounds' | 'zIndex'>;
Pick<UseTooltipPortalOptions, 'detectBounds' | 'portalContainer' | 'zIndex'>;

export type UseTooltipInPortal = {
containerRef: (element: HTMLElement | SVGElement | null) => void;
Expand All @@ -24,6 +25,8 @@ export type UseTooltipPortalOptions = Pick<PortalProps, 'zIndex'> & {
scroll?: boolean;
/** You can optionally inject a ResizeObserver polyfill. */
polyfill?: BaseUseMeasureOptions['polyfill'];
/** Optional container for the portal. */
portalContainer?: HTMLDivElement;
};

/**
Expand All @@ -32,16 +35,34 @@ export type UseTooltipPortalOptions = Pick<PortalProps, 'zIndex'> & {
*/
export default function useTooltipInPortal({
detectBounds: detectBoundsOption = true,
portalContainer,
zIndex: zIndexOption,
...useMeasureOptions
}: UseTooltipPortalOptions | undefined = {}): UseTooltipInPortal {
const [containerRef, containerBounds, forceRefreshBounds] = useMeasure(useMeasureOptions);

const [portalContainerRect, setPortalContainerRect] = useState<DOMRect | null>(
portalContainer?.getBoundingClientRect() ?? null,
);

const updatePortalContainerRect = useCallback(() => {
if (portalContainer) {
setPortalContainerRect(portalContainer?.getBoundingClientRect());
}
}, [portalContainer]);

React.useEffect(updatePortalContainerRect, [
containerBounds,
portalContainer,
updatePortalContainerRect,
]);
useResizeObserver(portalContainer ?? null, updatePortalContainerRect);

const TooltipInPortal = useMemo(
() =>
function ({
left: containerLeft = 0,
top: containerTop = 0,
left: tooltipLeft = 0,
top: tooltipTop = 0,
detectBounds: detectBoundsProp, // allow override at component-level
zIndex: zIndexProp, // allow override at the component-level
...tooltipProps
Expand All @@ -50,16 +71,41 @@ export default function useTooltipInPortal({
const zIndex = zIndexProp == null ? zIndexOption : zIndexProp;
const TooltipComponent = detectBounds ? TooltipWithBounds : Tooltip;
// convert container coordinates to page coordinates
const portalLeft = containerLeft + (containerBounds.left || 0) + window.scrollX;
const portalTop = containerTop + (containerBounds.top || 0) + window.scrollY;
const portalLeft = portalContainer
? tooltipLeft - (portalContainerRect?.left || 0) + (containerBounds.left || 0)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is window.scrollX/Y not relevant for these?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They are not indeed. I don't know enough about the internals of React portals to explain exactly why, but in the case when a custom portal container is used, including window.scrollX/Y in the calculation of the tooltip position leads to bugs like this:

Screen.Recording.2022-11-10.at.2.28.47.PM.mov

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah maybe when using a custom container, the window x/y is already accounted for. thanks for the video!

: tooltipLeft + (containerBounds.left || 0) + window.scrollX;
const portalTop = portalContainer
? tooltipTop - (portalContainerRect?.top || 0) + (containerBounds.top || 0)
: tooltipTop + (containerBounds.top || 0) + window.scrollY;

const additionalTooltipProps =
detectBounds && portalContainer
? {
portalContainerPosition: {
left: portalContainerRect?.left || 0,
top: portalContainerRect?.top || 0,
},
visualParentRect: {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do you think this should always be passed as props even without a portal?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say yes ideally, for consistency and because it would fix the bound detection for the case of tooltips in the default portal.

That being said, since we're dealing with a library here and the bound detection has been broken since the beginning when rendering the tooltip in the default portal, I'm worried that people may have worked under the assumption that it would stay that way, and that fixing this bound detection retroactively may break the tooltip position in their respective apps. That's the reason why I only applied this prop to the case of a custom portal container.

But if you feel confident that we could do this without inadvertently breaking other users' tooltips, I'd be down for doing it, just let me know!

width: containerBounds.width,
height: containerBounds.height,
left: containerBounds.left,
top: containerBounds.top,
},
}
: {};

return (
<Portal zIndex={zIndex}>
<TooltipComponent left={portalLeft} top={portalTop} {...tooltipProps} />
<Portal container={portalContainer} zIndex={zIndex}>
<TooltipComponent
left={portalLeft}
top={portalTop}
{...tooltipProps}
{...additionalTooltipProps}
/>
</Portal>
);
},
[detectBoundsOption, zIndexOption, containerBounds.left, containerBounds.top],
[containerBounds, detectBoundsOption, portalContainer, portalContainerRect, zIndexOption],
);

return {
Expand Down
41 changes: 33 additions & 8 deletions packages/visx-tooltip/src/tooltips/TooltipWithBounds.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,20 @@ import { TooltipPositionProvider } from '../context/TooltipPositionContext';

export type TooltipWithBoundsProps = TooltipProps &
React.HTMLAttributes<HTMLDivElement> &
WithBoundingRectsProps & { nodeRef?: React.Ref<HTMLDivElement> };
WithBoundingRectsProps & {
nodeRef?: React.Ref<HTMLDivElement>;
/**
* When the tooltip is in a portal, this is the position of the portal
* container to be used for offsetting calculations around bounds as a consequence.
*/
portalContainerPosition?: { left: number; top: number };
/**
* When the tooltip is in a portal, the portal container becomes the direct parent of the tooltip.
* Often the portal is not what we want the tooltip to be bound to. Another visual parent can be specified
* by specifying its dimensions here.
*/
visualParentRect?: { width: number; height: number; left: number; top: number };
Comment on lines +11 to +21
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The TooltipWithBounds case did not work at all for tooltips in a portal, because it wasn't considering the right visual parent for the tooltip.

I did not change the behavior for the case with no custom portal container for backward compatibility concerns, but I did get it to work as expected in the case when a custom portal container is provided and when the bounds options are on. I think this will help my team fix a bug where the bottom of our tooltip gets cropped sometimes.

Fixing it was not super intuitive at first, I hope my comments and the variable names make sufficient sense to understand what's going on here.

};

function TooltipWithBounds({
children,
Expand All @@ -20,19 +33,26 @@ function TooltipWithBounds({
top: initialTop = 0,
unstyled = false,
nodeRef,
portalContainerPosition,
visualParentRect: visualParentBounds = parentBounds,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather than introducing a new prop (this component is getting super complex), could TooltipInPortal simply overwrite parentRect and it's always assumed to be the visual parent?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did try to overwrite the parentRect prop when I worked on this, but whatever I passed from here was being overwritten by the withBoundingRects decorator, which is what sets the parentRect prop. The only way I see around this would be to refactor the TooltipWithBounds/withBoundingRects couple, but that feels risky and beyond the scope of this PR. 😕

...otherProps
}: TooltipWithBoundsProps) {
let transform: React.CSSProperties['transform'];
let placeTooltipLeft = false;
let placeTooltipUp = false;

if (ownBounds && parentBounds) {
if (ownBounds && visualParentBounds) {
let left = initialLeft;
let top = initialTop;

if (parentBounds.width) {
const rightPlacementClippedPx = left + offsetLeft + ownBounds.width - parentBounds.width;
const leftPlacementClippedPx = ownBounds.width - left - offsetLeft;
if (visualParentBounds.width) {
const leftInVisualParent =
left +
(portalContainerPosition?.left || 0) -
(portalContainerPosition ? visualParentBounds.left : 0);
const rightPlacementClippedPx =
leftInVisualParent + offsetLeft + ownBounds.width - visualParentBounds.width;
const leftPlacementClippedPx = ownBounds.width - leftInVisualParent - offsetLeft;
placeTooltipLeft =
rightPlacementClippedPx > 0 && rightPlacementClippedPx > leftPlacementClippedPx;
} else {
Expand All @@ -42,9 +62,14 @@ function TooltipWithBounds({
rightPlacementClippedPx > 0 && rightPlacementClippedPx > leftPlacementClippedPx;
}

if (parentBounds.height) {
const bottomPlacementClippedPx = top + offsetTop + ownBounds.height - parentBounds.height;
const topPlacementClippedPx = ownBounds.height - top - offsetTop;
if (visualParentBounds.height) {
const topInVisualParent =
top +
(portalContainerPosition?.top || 0) -
(portalContainerPosition ? visualParentBounds.top : 0);
const bottomPlacementClippedPx =
topInVisualParent + offsetTop + ownBounds.height - visualParentBounds.height;
const topPlacementClippedPx = ownBounds.height - topInVisualParent - offsetTop;
placeTooltipUp =
bottomPlacementClippedPx > 0 && bottomPlacementClippedPx > topPlacementClippedPx;
} else {
Expand Down
Loading