This is an automated email from the ASF dual-hosted git repository.

bbovenzi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/main by this push:
     new 04d39ab9741 UI: Keep Grid run tooltips inside the viewport (#72161)
04d39ab9741 is described below

commit 04d39ab97416b6128e830c5c0a2dd904cba9fc1f
Author: NihalRadhakrishna <[email protected]>
AuthorDate: Wed Sep 16 23:59:15 2026 +0530

    UI: Keep Grid run tooltips inside the viewport (#72161)
    
    * UI: Keep Grid run tooltips inside the viewport
    
    * UI: Replace BasicTooltip with Chakra Tooltip
    
    * UI: Fix GridButton import grouping after rebase.
    
    * UI: Point Calendar Tooltip at system-components after the ui folder 
rename.
---
 .../src/airflow/ui/src/components/BasicTooltip.tsx | 134 ---------------------
 .../src/layouts/Details/Grid/GridButton.test.tsx   |  24 ++--
 .../ui/src/layouts/Details/Grid/GridButton.tsx     | 121 +++++++++++--------
 .../ui/src/pages/Dag/Calendar/CalendarCell.tsx     |  19 ++-
 .../airflow/ui/tests/e2e/pages/DagCalendarTab.ts   |   2 +-
 5 files changed, 100 insertions(+), 200 deletions(-)

diff --git a/airflow-core/src/airflow/ui/src/components/BasicTooltip.tsx 
b/airflow-core/src/airflow/ui/src/components/BasicTooltip.tsx
deleted file mode 100644
index ccedc5b947c..00000000000
--- a/airflow-core/src/airflow/ui/src/components/BasicTooltip.tsx
+++ /dev/null
@@ -1,134 +0,0 @@
-/*!
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import type { ReactElement, ReactNode } from "react";
-import { useEffect, useLayoutEffect, useRef, useState } from "react";
-
-import { Box, Portal } from "@chakra-ui/react";
-
-type Props = {
-  readonly children: ReactNode;
-  readonly content: ReactNode;
-};
-
-const offset = 8;
-
-export const BasicTooltip = ({ children, content }: Props): ReactElement => {
-  const triggerRef = useRef<HTMLSpanElement>(null);
-  const tooltipRef = useRef<HTMLDivElement>(null);
-  const [isOpen, setIsOpen] = useState(false);
-  const [showOnTop, setShowOnTop] = useState(false);
-  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
-
-  const handleMouseEnter = () => {
-    if (timeoutRef.current !== null) {
-      clearTimeout(timeoutRef.current);
-    }
-    timeoutRef.current = setTimeout(() => {
-      setIsOpen(true);
-    }, 500);
-  };
-
-  const handleMouseLeave = () => {
-    if (timeoutRef.current !== null) {
-      clearTimeout(timeoutRef.current);
-      timeoutRef.current = null;
-    }
-    setIsOpen(false);
-  };
-
-  // Calculate position based on actual tooltip height before paint
-  useLayoutEffect(() => {
-    if (isOpen && triggerRef.current && tooltipRef.current) {
-      const triggerRect = triggerRef.current.getBoundingClientRect();
-      const tooltipHeight = tooltipRef.current.clientHeight;
-      const wouldOverflow = triggerRect.bottom + offset + tooltipHeight > 
globalThis.innerHeight;
-
-      setShowOnTop(wouldOverflow);
-    }
-  }, [isOpen]);
-
-  // Cleanup on unmount
-  useEffect(
-    () => () => {
-      if (timeoutRef.current !== null) {
-        clearTimeout(timeoutRef.current);
-      }
-    },
-    [],
-  );
-
-  const trigger = (
-    <Box
-      as="span"
-      display="inline-block"
-      onMouseEnter={handleMouseEnter}
-      onMouseLeave={handleMouseLeave}
-      ref={triggerRef}
-    >
-      {children}
-    </Box>
-  );
-
-  if (!isOpen || !triggerRef.current) {
-    return trigger;
-  }
-
-  const rect = triggerRef.current.getBoundingClientRect();
-  const { scrollX, scrollY } = globalThis;
-
-  return (
-    <>
-      {trigger}
-      <Portal>
-        <Box
-          bg="bg.inverted"
-          borderRadius="md"
-          boxShadow="md"
-          color="fg.inverted"
-          data-testid="basic-tooltip"
-          fontSize="sm"
-          left={`${rect.left + scrollX + rect.width / 2}px`}
-          paddingX="3"
-          paddingY="2"
-          pointerEvents="none"
-          position="absolute"
-          ref={tooltipRef}
-          top={showOnTop ? `${rect.top + scrollY - offset}px` : `${rect.bottom 
+ scrollY + offset}px`}
-          transform={showOnTop ? "translate(-50%, -100%)" : "translateX(-50%)"}
-          whiteSpace="nowrap"
-          zIndex="popover"
-        >
-          <Box
-            borderLeft="4px solid transparent"
-            borderRight="4px solid transparent"
-            height={0}
-            left="50%"
-            position="absolute"
-            transform="translateX(-50%)"
-            width={0}
-            {...(showOnTop
-              ? { borderTop: "4px solid var(--chakra-colors-bg-inverted)", 
bottom: "-4px" }
-              : { borderBottom: "4px solid var(--chakra-colors-bg-inverted)", 
top: "-4px" })}
-          />
-          {content}
-        </Box>
-      </Portal>
-    </>
-  );
-};
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.test.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.test.tsx
index f4bdfacd609..5a773f27fe3 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.test.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.test.tsx
@@ -26,7 +26,7 @@ import { Wrapper } from "src/utils/Wrapper";
 import { GridButton } from "./GridButton";
 
 describe("GridButton", () => {
-  it("shows run details in the grid tooltip respecting the selected timezone", 
() => {
+  it("shows run details in the grid tooltip respecting the selected timezone", 
async () => {
     vi.useFakeTimers();
 
     render(
@@ -45,17 +45,19 @@ describe("GridButton", () => {
       { wrapper: Wrapper },
     );
 
-    act(() => {
-      fireEvent.mouseEnter(screen.getByText("bar"));
-      vi.advanceTimersByTime(500);
-    });
+    try {
+      await act(async () => {
+        fireEvent.pointerEnter(screen.getByText("bar"));
+        await vi.advanceTimersByTimeAsync(500);
+      });
 
-    expect(screen.getByTestId("basic-tooltip")).toHaveTextContent("2026-04-21 
02:00:00");
-    expect(screen.getByTestId("basic-tooltip")).toHaveTextContent(
-      "common:runId: manual__2026-04-21T00:00:00+00:00",
-    );
-    expect(screen.getByTestId("basic-tooltip")).toHaveTextContent("duration: 
1h 1m");
+      const tooltip = screen.getByRole("tooltip");
 
-    vi.useRealTimers();
+      expect(tooltip).toHaveTextContent("2026-04-21 02:00:00");
+      expect(tooltip).toHaveTextContent("common:runId: 
manual__2026-04-21T00:00:00+00:00");
+      expect(tooltip).toHaveTextContent("duration: 1h 1m");
+    } finally {
+      vi.useRealTimers();
+    }
   });
 });
diff --git 
a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.tsx 
b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.tsx
index 0a15583dd36..fa2015e810b 100644
--- a/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.tsx
+++ b/airflow-core/src/airflow/ui/src/layouts/Details/Grid/GridButton.tsx
@@ -16,13 +16,14 @@
  * specific language governing permissions and limitations
  * under the License.
  */
-import { Flex, type FlexProps } from "@chakra-ui/react";
+import { Box, Flex, type FlexProps, Text, VStack } from "@chakra-ui/react";
 import { useTranslation } from "react-i18next";
 import { Link } from "react-router-dom";
 
 import type { DagRunState, TaskInstanceState } from 
"openapi/requests/types.gen";
 
-import { BasicTooltip } from "src/components/BasicTooltip";
+import { Tooltip } from "src/system-components";
+
 import Time from "src/components/Time";
 
 import { useDurationFormat } from "src/utils";
@@ -53,54 +54,72 @@ export const GridButton = ({
   const { t: translate } = useTranslation();
   const { renderDuration } = useDurationFormat();
 
-  const tooltipContent = (
-    <>
-      <Time datetime={runAfter} />
-      <br />
-      {translate("common:runId")}: {runId}
-      <br />
-      {translate("state")}:{" "}
-      {state ? translate(`common:states.${state}`) : 
translate("common:states.no_status")}
-      <br />
-      {translate("duration")}: {renderDuration(duration)}
-    </>
-  );
-
-  return isGroup ? (
-    <BasicTooltip content={tooltipContent}>
-      <Flex
-        background={`${state}.solid`}
-        borderRadius={2}
-        height="10px"
-        minW="14px"
-        pb="2px"
-        px="2px"
-        {...rest}
-      >
-        {children}
-      </Flex>
-    </BasicTooltip>
-  ) : (
-    <BasicTooltip content={tooltipContent}>
-      <Link
-        replace
-        to={{
-          pathname: `/dags/${dagId}/runs/${runId}/${taskId === undefined ? "" 
: `tasks/${taskId}`}`,
-          search: searchParams.toString(),
-        }}
-      >
-        <Flex
-          background={`${state}.solid`}
-          borderRadius={2}
-          height="10px"
-          pb="2px"
-          px="2px"
-          width="14px"
-          {...rest}
-        >
-          {children}
-        </Flex>
-      </Link>
-    </BasicTooltip>
+  return (
+    <Tooltip
+      content={
+        <VStack align="start" gap={1}>
+          <Text>
+            <Time datetime={runAfter} />
+          </Text>
+          <Text>
+            {translate("common:runId")}: {runId}
+          </Text>
+          <Text>
+            {translate("state")}:{" "}
+            {state ? translate(`common:states.${state}`) : 
translate("common:states.no_status")}
+          </Text>
+          <Text>
+            {translate("duration")}: {renderDuration(duration)}
+          </Text>
+        </VStack>
+      }
+      lazyMount
+      openDelay={500}
+      portalled
+      positioning={{
+        offset: {
+          crossAxis: 5,
+          mainAxis: 5,
+        },
+        placement: "bottom",
+      }}
+      unmountOnExit
+    >
+      <Box as="span" display="inline-block">
+        {isGroup ? (
+          <Flex
+            background={`${state}.solid`}
+            borderRadius={2}
+            height="10px"
+            minW="14px"
+            pb="2px"
+            px="2px"
+            {...rest}
+          >
+            {children}
+          </Flex>
+        ) : (
+          <Link
+            replace
+            to={{
+              pathname: `/dags/${dagId}/runs/${runId}/${taskId === undefined ? 
"" : `tasks/${taskId}`}`,
+              search: searchParams.toString(),
+            }}
+          >
+            <Flex
+              background={`${state}.solid`}
+              borderRadius={2}
+              height="10px"
+              pb="2px"
+              px="2px"
+              width="14px"
+              {...rest}
+            >
+              {children}
+            </Flex>
+          </Link>
+        )}
+      </Box>
+    </Tooltip>
   );
 };
diff --git 
a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx 
b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx
index 375aaf86542..c5059691e4e 100644
--- a/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx
+++ b/airflow-core/src/airflow/ui/src/pages/Dag/Calendar/CalendarCell.tsx
@@ -19,7 +19,7 @@
 import { Box } from "@chakra-ui/react";
 import { FiAlertTriangle, FiClock } from "react-icons/fi";
 
-import { BasicTooltip } from "src/components/BasicTooltip";
+import { Tooltip } from "src/system-components";
 
 import { CalendarTooltip } from "./CalendarTooltip";
 import type { CalendarCellData, CalendarColorMode } from "./types";
@@ -146,8 +146,21 @@ export const CalendarCell = ({
   }
 
   return (
-    <BasicTooltip content={<CalendarTooltip cellData={cellData} 
viewMode={viewMode} />}>
+    <Tooltip
+      content={<CalendarTooltip cellData={cellData} viewMode={viewMode} />}
+      lazyMount
+      openDelay={500}
+      portalled
+      positioning={{
+        offset: {
+          crossAxis: 5,
+          mainAxis: 5,
+        },
+        placement: "bottom",
+      }}
+      unmountOnExit
+    >
       {cellBox}
-    </BasicTooltip>
+    </Tooltip>
   );
 };
diff --git a/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts 
b/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts
index b0656bc5575..3da576a8421 100644
--- a/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts
+++ b/airflow-core/src/airflow/ui/tests/e2e/pages/DagCalendarTab.ts
@@ -90,7 +90,7 @@ export class DagCalendarTab extends BasePage {
     const states: Array<string> = [];
 
     // Read run states from the cell's `data-states` attribute rather than 
hovering to
-    // read the tooltip. The tooltip (BasicTooltip) opens on a `mouseenter` 
after a
+    // read the tooltip. The tooltip opens on a `mouseenter` after a
     // 500ms delay and renders through a portal; synthetic pointer events do 
not open
     // it reliably in headless Firefox, which made these tests flaky. 
`data-states` is
     // populated with the same view-mode-aware logic the tooltip uses (see

Reply via email to