Repository: getsentry/sentry
Pull request: feat(tooltip): Create tooltip context
We do not want the tooltip component to be aware of the global modals state, so this PR adds a tooltip context that we use inside the global modal

closes https://github.com/getsentry/sentry/issues/75624

Problem statement:
Create a Tooltip Context to set the tooltip's position
Work on some kind of tooltip context instead of making the tooltip aware of modals. Basically the context would tell the tooltip where to render. So by default without a context it would render into the body. But then if a tooltip is rendered within the tooltip context then it would render somewhere else. Basically the global modal could be wrapped in that context, so tooltips rendered inside modals would know to render somewhere else

Hints from issue discussion:

Commit message: add test

Patch to review:
diff --git a/static/app/components/globalModal/index.spec.tsx b/static/app/components/globalModal/index.spec.tsx
index 6eeacc3dace6bc..1d0d5c01d11e3b 100644
--- a/static/app/components/globalModal/index.spec.tsx
+++ b/static/app/components/globalModal/index.spec.tsx
@@ -7,6 +7,8 @@ import {
 } from 'sentry-test/reactTestingLibrary';
 
 import {closeModal, openModal} from 'sentry/actionCreators/modal';
+import {Button} from 'sentry/components/button';
+import {Tooltip} from 'sentry/components/tooltip';
 import ModalStore from 'sentry/stores/modalStore';
 
 describe('GlobalModal', function () {
@@ -190,4 +192,25 @@ describe('GlobalModal', function () {
     expect(closeSpy).toHaveBeenCalled();
     await waitForModalToHide();
   });
+
+  it('renders interactive tooltip inside the modal', async function () {
+    renderGlobalModal();
+
+    const buttonClick = jest.fn();
+
+    act(() =>
+      openModal(({Body}) => (
+        <Body>
+          <Tooltip title={<Button onClick={buttonClick}>Click me</Button>} isHoverable>
+            Hi
+          </Tooltip>
+        </Body>
+      ))
+    );
+
+    await userEvent.hover(screen.getByText('Hi'));
+    await userEvent.click(await screen.findByText('Click me'));
+
+    expect(buttonClick).toHaveBeenCalled();
+  });
 });
diff --git a/static/app/components/globalModal/index.tsx b/static/app/components/globalModal/index.tsx
index 24322084539d6a..c3a82d5d5ff9e0 100644
--- a/static/app/components/globalModal/index.tsx
+++ b/static/app/components/globalModal/index.tsx
@@ -8,6 +8,7 @@ import {AnimatePresence, motion} from 'framer-motion';
 
 import {closeModal as actionCloseModal} from 'sentry/actionCreators/modal';
 import {useGlobalModal} from 'sentry/components/globalModal/useGlobalModal';
+import {TooltipContext} from 'sentry/components/tooltip';
 import {ROOT_ELEMENT} from 'sentry/constants';
 import ModalStore from 'sentry/stores/modalStore';
 import {space} from 'sentry/styles/space';
@@ -226,7 +227,17 @@ function GlobalModal({onClose}: Props) {
         <AnimatePresence>
           {visible && (
             <Modal role="dialog" aria-modal css={options.modalCss}>
-              <Content role="document">{renderedChild}</Content>
+              <Content role="document">
+                <TooltipContext.Provider
+                  value={{
+                    // To ensure tooltips within the modal remain interactive (e.g., clickable or selectable),
+                    // they need to be rendered inside the modal's DOM node.
+                    container: portal,
+                  }}
+                >
+                  {renderedChild}
+                </TooltipContext.Provider>
+              </Content>
             </Modal>
           )}
         </AnimatePresence>
diff --git a/static/app/components/tooltip.tsx b/static/app/components/tooltip.tsx
index 459b1a19a48a26..07e81e70586c86 100644
--- a/static/app/components/tooltip.tsx
+++ b/static/app/components/tooltip.tsx
@@ -1,17 +1,21 @@
-import {Fragment, useEffect} from 'react';
+import {createContext, Fragment, useContext, useEffect} from 'react';
 import {createPortal} from 'react-dom';
 import type {SerializedStyles} from '@emotion/react';
 import {useTheme} from '@emotion/react';
 import styled from '@emotion/styled';
 import {AnimatePresence} from 'framer-motion';
 
-import {useGlobalModal} from 'sentry/components/globalModal/useGlobalModal';
 import {Overlay, PositionWrapper} from 'sentry/components/overlay';
 import {space} from 'sentry/styles/space';
-import getModalPortal from 'sentry/utils/getModalPortal';
 import type {UseHoverOverlayProps} from 'sentry/utils/useHoverOverlay';
 import {useHoverOverlay} from 'sentry/utils/useHoverOverlay';
 
+interface TooltipContextProps {
+  container: Parameters<typeof createPortal>[1] | undefined;
+}
+
+export const TooltipContext = createContext<TooltipContextProps>({container: undefined});
+
 interface TooltipProps extends UseHoverOverlayProps {
   /**
    * The content to show in the tooltip popover
@@ -35,8 +39,8 @@ function Tooltip({
   disabled = false,
   ...hoverOverlayProps
 }: TooltipProps) {
+  const context = useContext(TooltipContext);
   const theme = useTheme();
-  const {visible: modalIsVisible} = useGlobalModal();
   const {wrapTrigger, isOpen, overlayProps, placement, arrowData, arrowProps, reset} =
     useHoverOverlay('tooltip', hoverOverlayProps);
 
@@ -65,14 +69,13 @@ function Tooltip({
     </PositionWrapper>
   );
 
-  // If the tooltip is rendered outside the modal's DOM node, it will be unclickable and unselectable.
-  // Therefore, we check if the global modal is active. If it is, the tooltip should be rendered within the same node to ensure interactivity.
-  const container = modalIsVisible ? getModalPortal() : document.body;
-
   return (
     <Fragment>
       {wrapTrigger(children)}
-      {createPortal(<AnimatePresence>{tooltipContent}</AnimatePresence>, container)}
+      {createPortal(
+        <AnimatePresence>{tooltipContent}</AnimatePresence>,
+        context.container ?? document.body
+      )}
     </Fragment>
   );
 }


Code review comments:
diff_hunk: @@ -7,6 +7,8 @@ import {
 } from 'sentry-test/reactTestingLibrary';
 
 import {closeModal, openModal} from 'sentry/actionCreators/modal';
+import {Button} from 'sentry/components/button';
path: static/app/components/globalModal/index.spec.tsx
text: @user1:
No need to import our button here for the test, just use a regular `button`

diff_hunk: @@ -65,14 +69,13 @@ function Tooltip({
     </PositionWrapper>
   );
 
-  // If the tooltip is rendered outside the modal's DOM node, it will be unclickable and unselectable.
-  // Therefore, we check if the global modal is active. If it is, the tooltip should be rendered within the same node to ensure interactivity.
-  const container = modalIsVisible ? getModalPortal() : document.body;
-
   return (
     <Fragment>
       {wrapTrigger(children)}
-      {createPortal(<AnimatePresence>{tooltipContent}</AnimatePresence>, container)}
+      {createPortal(
+        <AnimatePresence>{tooltipContent}</AnimatePresence>,
+        context.container ?? document.body
path: static/app/components/tooltip.tsx
text: @user1:
Why not just default `context.container` to `document.body`?

@author:
ok we can do that too