-
Notifications
You must be signed in to change notification settings - Fork 718
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
base: master
Are you sure you want to change the base?
feat(tooltip): Enable custom portal container for tooltip #1567
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the contribution and adding tests/updating the docs @fbouquet! 🙏
I had a couple of comments but overall looks great.
placeAfterTooltipInDom?: boolean; | ||
}; | ||
|
||
const OverlayLayer = function OverlayLayer({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit – this could be simplified slightly
const OverlayLayer = function OverlayLayer({ | |
function OverlayLayer({ |
packages/visx-tooltip/src/Portal.tsx
Outdated
document.body.removeChild(this.node); | ||
delete this.node; | ||
} | ||
} | ||
|
||
render() { | ||
if (!this.node && this.props.container) { | ||
this.node = this.props.container; | ||
if (this.props.zIndex != null) this.node.style.zIndex = `${this.props.zIndex}`; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if someone is providing their own container, it seems a bit strange for us to handle setting styles on it. I guess this is fine tho and consumers can always set it to null
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a good point, I had set this up for the zIndex
to still do something in this case too, but actually I don't see a point in doing it that way, because consumers can just directly set the z-index of the portal container in their styles directly. 🤷♂️ I'll go ahead and remove this line.
</Portal> | ||
); | ||
}, | ||
[detectBoundsOption, zIndexOption, containerBounds.left, containerBounds.top], | ||
[containerBounds, detectBoundsOption, portalContainerOption, zIndexOption], |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think it's ideal to have this reference containerBounds.left/top
explicitly since the object containerBounds
might not be referentially equal across renders.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Totally fair, I assumed that would never be the case based on the type of containerBounds
being RectReadOnly
where left
and top
are readonly properties, but better safe than sorry. 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ha, actually, eslint is yelling at me saying that containerBounds.left
and containerBounds.top
are unnecessary dependencies when I add them. 🙈 Do you think there's actually any reasonable change for the readonly
modifier on left
and top
to be violated on this object?
zIndex: zIndexProp, // allow override at the component-level | ||
...tooltipProps | ||
}: TooltipInPortalProps) => { | ||
const detectBounds = detectBoundsProp == null ? detectBoundsOption : detectBoundsProp; | ||
const portalContainer = | ||
portalContainerProp == null ? portalContainerOption : portalContainerProp; | ||
const portalContainerRect = portalContainer?.getBoundingClientRect(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this can be quite expensive to call each render which is why we opted to use useMeasure
for the container
.
Is it necessary to support the portalContainerProp
override or can it be passed in as an option? If the latter is fine we could use useMeasure
for the portalContainer
as well. that's tricky though because it may not always update when an element resize happens etc. (which is what forceRefreshBounds
is for)
if this simplifies things we could leave it for now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh that's a good catch. 🤔 I don't think we necessarily need to support the override at the component level since typically a portal container doesn't move in the screen. I'll drop this prop at the tooltip level and just keep it on useTooltipInPortal
.
However, I'll keep passing in the HTMLDivElement
directly to useTooltipInPortal
and memoize the bounding rect instead of using useMeasure
for this if that's OK, because having to deal with the old fashioned ref useMeasure
returns makes things harder on the consumer side in my opinion.
|
||
it('should render children at the document root', async () => { | ||
render( | ||
<> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would this make more sense as a test setup since you wouldn't expect to find element-in-portal
inside inner-div
if it's a sibling?
<> | |
<div data-testid="inner-div"> | |
<Portal> | |
<div data-testid="element-in-portal" /> | |
</Portal> | |
</div> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a great point!
const elementInPortal = await screen.findByTestId('element-in-tooltip'); | ||
expect(elementInPortal).toBeInTheDocument(); | ||
expect( | ||
within(screen.getByTestId('inner-div')).queryByTestId('element-in-tooltip'), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I guess same question on this test since element-in-tooltip
is a sibling / wouldn't be expected to be a child of inner-div
describe('Portal', () => { | ||
test('it should be defined', () => { | ||
expect(Portal).toBeDefined(); | ||
}); | ||
|
||
it('should render children at the document root', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks for adding such great tests! 🙌
Thanks a lot for the quick code review @williaster ! I went ahead and updated the code based on your comments. I'll be away from keyboard for the next two weeks but if there's anything else that need to be changed I'll be happy to look into it when I'm back. |
Hi @williaster ! Just wanted to double check if there's anything else needed from me on this PR or if it's all good to go? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hey @fbouquet thanks again for the addition and your patience getting this in. I have one big concern with this as it stands.
zIndex: zIndexOption, | ||
...useMeasureOptions | ||
}: UseTooltipPortalOptions | undefined = {}): UseTooltipInPortal { | ||
const [containerRef, containerBounds, forceRefreshBounds] = useMeasure(useMeasureOptions); | ||
|
||
const portalContainerRect = useMemo( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm, I don't think this will necessarily update the way we want, it's just computed once and if the container changes sizes or moves this will be stale/wrong.
this is the advantage of useMeasure
, I'm a bit apprehensive of adding this if it's potentially going to result in issues of it being stale / incorrectly positioned.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was a really good point indeed! I was to simulate containers moving / changing sizes in the demo page and to confirm that my prior implementation didn't handle that gracefully.
Unfortunately useMeasure
wouldn't work here because of the legacy type of ref it generates, which doesn't let us get the actual HTML element from the ref when we need it in Portal
. That is a known issue of this library and it turns out that the library is not maintained anymore. In the issue, there are recommendations to use @react-hookz/web instead, but I couldn't find any way to target a specific hook in the library and importing the whole library just to use one hook didn't feel like a good idea.
Instead, I've opted for using @react-hook/resize-observer, and I was able to verify that my solution works as expected when containers move and change sizes.
This was before the fix, with randomly changing container positions and sizes:
Screen.Recording.2022-11-10.at.2.25.27.PM.mov
And this is after the fix:
Screen.Recording.2022-11-10.at.2.26.52.PM.Shorter.mov
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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!
Hi @williaster ! Thank you, those are good points, I'll be looking into this within the next 2 weeks and will get back to you. 🙂 |
0601c34
to
ab3353f
Compare
/** | ||
* 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 }; |
There was a problem hiding this comment.
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.
@williaster Apologies for the additional delay on this, the last few weeks have been busier than I had expected. I went ahead and updated my code to correctly handle container changes in position and size, and I also fixed the Unfortunately that behavior is hard to cover in automated tests but I was able to manually ensure that things are working well with my changes, as you can see in the screen recording above. 🙂 |
Hi @williaster ! 👋 Just wanted to send you a reminder about this PR; anything I should change before it's ready to go? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for another great iteratio here @fbouquet 🙏
I had a couple remaining comments, some to simplify the API and some to simplify the example. Maybe we can try to land this before EOY! I've been busy so haven't had a lot of time but will try to keep an eye on it.
@@ -28,11 +29,17 @@ const tooltipStyles = { | |||
|
|||
export default function Example({ width, height, showControls = true }: TooltipProps) { | |||
const [tooltipShouldDetectBounds, setTooltipShouldDetectBounds] = useState(true); | |||
const [tooltipPortalShouldUseCustomContainer, setTooltipPortalShouldUseCustomContainer] = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
simplifying a bit
const [tooltipPortalShouldUseCustomContainer, setTooltipPortalShouldUseCustomContainer] = | |
const [tooltipShouldUseCustomContainer, setTooltipShouldUseCustomContainer] = |
placeAfterTooltipInDom?: boolean; | ||
}; | ||
|
||
function OverlayLayer({ className, container, placeAfterTooltipInDom, text }: OverlayLayerProps) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
maybe I'm not fully following the nuance, but I find that these make the example much more complex to understand / they don't directly relate to the tooltip API that we want to demonstrate. wdyt about removing them?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see your point about this making the example code more complex, but on the other hand I feel like having these elements in the example helps understand what the portalContainer
prop in useTooltipInPortal
can achieve and I'm worried that the point may be missed if we removed this from the example. 🤔 Plus, I think it's a nice way to visually make sure that nothing is broken when making changes to the tooltip module. I won't fight too hard if you feel strongly about trimming the example though. 🤷♂️
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) |
There was a problem hiding this comment.
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!
left: portalContainerRect?.left || 0, | ||
top: portalContainerRect?.top || 0, | ||
}, | ||
visualParentRect: { |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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!
@@ -20,19 +33,26 @@ function TooltipWithBounds({ | |||
top: initialTop = 0, | |||
unstyled = false, | |||
nodeRef, | |||
portalContainerPosition, | |||
visualParentRect: visualParentBounds = parentBounds, |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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. 😕
Thank you for the code review @williaster and apologies for the long delay of my response, the end of year has been pretty busy. I'll be dealing with your comments in the near future and will ping you when it's ready for another round. 🙂 |
Sounds great @fbouquet ! Will keep an eye out for your ping when you have time 👍 |
ab3353f
to
0ec63f5
Compare
@williaster I requested a new review last week but just realized I didn't ping you, so I'm doing that now to make sure that you see the notification. No big rush though! |
@williaster Just a reminder about this PR for whenever you get a chance to look into it. 🙂 |
Ran into an issue today with tooltips in portals due to the fact that we're using Tailwind with the |
🚀 Enhancements
useTooltipInPortal
, via theportalContainer
property.Currently, when using
useTooltipInPortal
, the tooltip renders at the root of the document. My team is running into an issue where we are sometimes rendering a dropdown in a static tooltip, but the dropdown menu appears behind the tooltip. Setting hacky z indexes would not robustly fix the issue if there is another overlay layer that is supposed to be under the tooltip, and we figured that if we could tell the tooltip portal where to render, we could fix the bug in a clean way.I've updated the tooltip example to show that use case:
Screen.Recording.2022-09-14.at.12.07.07.PM.mov
Screen.Recording.2022-09-14.at.12.07.34.PM.mov