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

pierrejeambrun pushed a commit to branch v2-11-test
in repository https://gitbox.apache.org/repos/asf/airflow.git


The following commit(s) were added to refs/heads/v2-11-test by this push:
     new c780893f642 Fix truncated details tabs and page overflow in the grid 
view (#68750)
c780893f642 is described below

commit c780893f642bd5e79f3676f0d5fe5b05f4c0b0bb
Author: Pierre Jeambrun <[email protected]>
AuthorDate: Fri Jul 3 13:35:10 2026 +0200

    Fix truncated details tabs and page overflow in the grid view (#68750)
    
    * Fix truncated details tabs and page overflow in the grid view
    
    After the Chakra UI 2.10 / React 19 bump, the grid view's height cascade
    stopped resolving correctly. The `height: 100%` chain over-claims through 
the
    Tabs/TabPanel chrome, so the details tabs render taller than their visible 
area
    (content is cut off with no way to scroll to it), and the main column 
pushes the
    page footer below the viewport.
    
    Anchor the heights to the viewport instead: a shared hook measures where 
each
    scroll container actually sits and subtracts the body's footer reserve, so 
every
    details tab scrolls within its own area and the page itself no longer 
overflows.
    
    * Mock ResizeObserver in the Jest setup for the WWW tests
    
    The new useContentHeight hook creates a ResizeObserver, which jsdom does not
    implement, so rendering any component that uses it throws
    `ReferenceError: ResizeObserver is not defined` and fails the React WWW test
    suite. Provide a no-op stub in the global Jest setup, next to the existing
    matchMedia mock.
    
    * Address PR comments
    
    * Fix grid detail tabs leaving blank space below their content
    
    When the grid panel is floored to its minimum height and grows taller than
    the viewport, its detail tabs (graph, event log, and others) were left with 
a
    blank strip beneath their content: each tab sized itself against the 
viewport
    fold rather than the panel it lives in, so it stopped short of the panel's
    actual bottom instead of filling the space it was given.
---
 airflow/www/jest-globals-setup.js                  | 10 ++++
 airflow/www/static/js/dag/Main.tsx                 | 25 ++------
 airflow/www/static/js/dag/details/EventLog.tsx     |  6 +-
 airflow/www/static/js/dag/details/dag/Dag.tsx      |  6 +-
 .../www/static/js/dag/details/dagCode/index.tsx    |  6 +-
 airflow/www/static/js/dag/details/dagRun/index.tsx |  6 +-
 airflow/www/static/js/dag/details/graph/index.tsx  |  8 +--
 .../js/dag/details/taskInstance/Logs/LogBlock.tsx  | 14 ++---
 .../dag/details/taskInstance/MappedInstances.tsx   | 10 +---
 .../js/dag/details/taskInstance/RenderedK8s.tsx    | 11 +---
 .../js/dag/details/taskInstance/Xcom/index.tsx     |  6 +-
 .../static/js/dag/details/taskInstance/index.tsx   |  7 +--
 airflow/www/static/js/utils/index.ts               |  2 +
 airflow/www/static/js/utils/useContentHeight.ts    | 69 ++++++++++++++++++++++
 14 files changed, 122 insertions(+), 64 deletions(-)

diff --git a/airflow/www/jest-globals-setup.js 
b/airflow/www/jest-globals-setup.js
index e24035ade93..9451578bab7 100644
--- a/airflow/www/jest-globals-setup.js
+++ b/airflow/www/jest-globals-setup.js
@@ -45,6 +45,16 @@ if (typeof globalThis.window !== "undefined" && 
!globalThis.window.matchMedia) {
   };
 }
 
+// Mock ResizeObserver (not implemented in jsdom) — used by useContentHeight
+if (typeof globalThis.ResizeObserver === "undefined") {
+  globalThis.ResizeObserver = function ResizeObserver(callback) {
+    this.callback = callback;
+    this.observe = () => {};
+    this.unobserve = () => {};
+    this.disconnect = () => {};
+  };
+}
+
 // Unwrap React 19 AggregateError for readable test failure messages
 const OrigAggregateError = globalThis.AggregateError;
 if (OrigAggregateError) {
diff --git a/airflow/www/static/js/dag/Main.tsx 
b/airflow/www/static/js/dag/Main.tsx
index 25fdcd4a1cd..7665d27fe35 100644
--- a/airflow/www/static/js/dag/Main.tsx
+++ b/airflow/www/static/js/dag/Main.tsx
@@ -35,7 +35,7 @@ import { isEmpty, debounce } from "lodash";
 import { FaExpandArrowsAlt, FaCompressArrowsAlt } from "react-icons/fa";
 
 import { useGridData } from "src/api";
-import { hoverDelay } from "src/utils";
+import { hoverDelay, useContentHeight } from "src/utils";
 
 import ShortcutCheatSheet from "src/components/ShortcutCheatSheet";
 import { useKeysPress } from "src/utils/useKeysPress";
@@ -56,21 +56,6 @@ const saveWidth = debounce(
   hoverDelay,
 );
 
-const footerHeight =
-  parseInt(
-    getComputedStyle(
-      document.getElementsByTagName("body")[0],
-    ).paddingBottom.replace("px", ""),
-    10,
-  ) || 0;
-const headerHeight =
-  parseInt(
-    getComputedStyle(
-      document.getElementsByTagName("body")[0],
-    ).paddingTop.replace("px", ""),
-    10,
-  ) || 0;
-
 const Main = () => {
   const {
     data: { groups },
@@ -85,6 +70,8 @@ const Main = () => {
   const gridRef = useRef<HTMLDivElement>(null);
   const gridScrollRef = useRef<HTMLDivElement>(null);
   const ganttScrollRef = useRef<HTMLDivElement>(null);
+  const mainRef = useRef<HTMLDivElement>(null);
+  const mainHeight = useContentHeight(mainRef);
 
   const [hoveredTaskState, setHoveredTaskState] = useState<
     string | null | undefined
@@ -199,11 +186,11 @@ const Main = () => {
   return (
     <Box
       flex={1}
-      height={`calc(100vh - ${footerHeight + headerHeight}px)`}
-      maxHeight={`calc(100vh - ${footerHeight + headerHeight}px)`}
-      minHeight="750px"
+      ref={mainRef}
+      height={`${mainHeight}px`}
       overflow="hidden"
       position="relative"
+      minHeight="750px"
     >
       <Accordion allowToggle index={accordionIndexes} borderTopWidth={0}>
         <AccordionItem
diff --git a/airflow/www/static/js/dag/details/EventLog.tsx 
b/airflow/www/static/js/dag/details/EventLog.tsx
index 735a193e3f4..93f7d4a127e 100644
--- a/airflow/www/static/js/dag/details/EventLog.tsx
+++ b/airflow/www/static/js/dag/details/EventLog.tsx
@@ -43,7 +43,7 @@ import {
 } from "chakra-react-select";
 
 import { useEventLogs } from "src/api";
-import { getMetaValue, useOffsetTop } from "src/utils";
+import { getMetaValue, useContentHeight } from "src/utils";
 import type { DagRun } from "src/types";
 import LinkButton from "src/components/LinkButton";
 import type { EventLog as EventLogType } from "src/types/api-generated";
@@ -75,7 +75,7 @@ const columnHelper = createColumnHelper<EventLogType>();
 
 const EventLog = ({ taskId, run, showMapped }: Props) => {
   const logRef = useRef<HTMLDivElement>(null);
-  const offsetTop = useOffsetTop(logRef);
+  const contentHeight = useContentHeight(logRef);
   const { tableURLState, setTableURLState } = useTableURLState({
     sorting: [{ id: "when", desc: true }],
   });
@@ -216,7 +216,7 @@ const EventLog = ({ taskId, run, showMapped }: Props) => {
   return (
     <Box
       height="100%"
-      maxHeight={`calc(100% - ${offsetTop}px)`}
+      maxHeight={`${contentHeight}px`}
       ref={logRef}
       overflowY="auto"
     >
diff --git a/airflow/www/static/js/dag/details/dag/Dag.tsx 
b/airflow/www/static/js/dag/details/dag/Dag.tsx
index c3ecefe0d4c..5d046c35f16 100644
--- a/airflow/www/static/js/dag/details/dag/Dag.tsx
+++ b/airflow/www/static/js/dag/details/dag/Dag.tsx
@@ -41,7 +41,7 @@ import {
   getMetaValue,
   getTaskSummary,
   toSentenceCase,
-  useOffsetTop,
+  useContentHeight,
 } from "src/utils";
 import { useGridData, useDagDetails } from "src/api";
 import Time from "src/components/Time";
@@ -61,7 +61,7 @@ const Dag = () => {
     data: { dagRuns, groups },
   } = useGridData();
   const detailsRef = useRef<HTMLDivElement>(null);
-  const offsetTop = useOffsetTop(detailsRef);
+  const contentHeight = useContentHeight(detailsRef);
 
   const { data: dagDetailsData, isLoading: isLoadingDagDetails } =
     useDagDetails();
@@ -160,7 +160,7 @@ const Dag = () => {
   return (
     <Box
       height="100%"
-      maxHeight={`calc(100% - ${offsetTop}px)`}
+      maxHeight={`${contentHeight}px`}
       ref={detailsRef}
       overflowY="auto"
     >
diff --git a/airflow/www/static/js/dag/details/dagCode/index.tsx 
b/airflow/www/static/js/dag/details/dagCode/index.tsx
index 1ade686a946..37a72da3a55 100644
--- a/airflow/www/static/js/dag/details/dagCode/index.tsx
+++ b/airflow/www/static/js/dag/details/dagCode/index.tsx
@@ -20,7 +20,7 @@
 import React, { useRef } from "react";
 import { Box, Heading, Spinner } from "@chakra-ui/react";
 
-import { useOffsetTop } from "src/utils";
+import { useContentHeight } from "src/utils";
 import Time from "src/components/Time";
 import { useDag, useDagCode } from "src/api";
 import ErrorAlert from "src/components/ErrorAlert";
@@ -29,7 +29,7 @@ import CodeBlock from "./CodeBlock";
 
 const DagCode = () => {
   const dagCodeRef = useRef<HTMLDivElement>(null);
-  const offsetTop = useOffsetTop(dagCodeRef);
+  const contentHeight = useContentHeight(dagCodeRef);
   const { data: dagData, isLoading: isLoadingDag, error: dagError } = useDag();
   const {
     data: codeSource = "",
@@ -41,7 +41,7 @@ const DagCode = () => {
   const error = codeError || dagError;
 
   return (
-    <Box ref={dagCodeRef} height={`calc(100% - ${offsetTop}px)`}>
+    <Box ref={dagCodeRef} height={`${contentHeight}px`}>
       {dagData?.lastParsedTime && (
         <Heading as="h4" size="md" paddingBottom="10px" fontSize="14px">
           Parsed at: <Time dateTime={dagData.lastParsedTime} />
diff --git a/airflow/www/static/js/dag/details/dagRun/index.tsx 
b/airflow/www/static/js/dag/details/dagRun/index.tsx
index 05679a598fa..9a3b7628ff5 100644
--- a/airflow/www/static/js/dag/details/dagRun/index.tsx
+++ b/airflow/www/static/js/dag/details/dagRun/index.tsx
@@ -20,7 +20,7 @@ import React, { useRef } from "react";
 import { Box } from "@chakra-ui/react";
 
 import { useGridData } from "src/api";
-import { getMetaValue, useOffsetTop } from "src/utils";
+import { getMetaValue, useContentHeight } from "src/utils";
 import type { DagRun as DagRunType } from "src/types";
 import NotesAccordion from "src/dag/details/NotesAccordion";
 
@@ -38,7 +38,7 @@ const DagRun = ({ runId }: Props) => {
     data: { dagRuns },
   } = useGridData();
   const detailsRef = useRef<HTMLDivElement>(null);
-  const offsetTop = useOffsetTop(detailsRef);
+  const contentHeight = useContentHeight(detailsRef);
 
   const run = dagRuns.find((dr) => dr.runId === runId);
 
@@ -47,7 +47,7 @@ const DagRun = ({ runId }: Props) => {
 
   return (
     <Box
-      maxHeight={`calc(100% - ${offsetTop}px)`}
+      maxHeight={`${contentHeight}px`}
       ref={detailsRef}
       overflowY="auto"
       pb={4}
diff --git a/airflow/www/static/js/dag/details/graph/index.tsx 
b/airflow/www/static/js/dag/details/graph/index.tsx
index 0ce05df7e1b..2d39dd07f36 100644
--- a/airflow/www/static/js/dag/details/graph/index.tsx
+++ b/airflow/www/static/js/dag/details/graph/index.tsx
@@ -39,7 +39,7 @@ import {
   useUpstreamDatasetEvents,
 } from "src/api";
 import useSelection from "src/dag/useSelection";
-import { getMetaValue, getTask, useOffsetTop } from "src/utils";
+import { getMetaValue, getTask, useContentHeight } from "src/utils";
 import { useGraphLayout } from "src/utils/graph";
 import Edge from "src/components/Graph/Edge";
 import type { DepNode, WebserverEdge } from "src/types";
@@ -244,7 +244,7 @@ const Graph = ({ openGroupIds, onToggleGroups, 
hoveredTaskState }: Props) => {
   const { colors } = useTheme();
   const { getZoom, fitView } = useReactFlow();
   const latestDagRunId = dagRuns[dagRuns.length - 1]?.runId;
-  const offsetTop = useOffsetTop(graphRef);
+  const contentHeight = useContentHeight(graphRef);
 
   useOnViewportChange({
     onEnd: (viewport: Viewport) => {
@@ -312,11 +312,11 @@ const Graph = ({ openGroupIds, onToggleGroups, 
hoveredTaskState }: Props) => {
   return (
     <Box
       ref={graphRef}
-      height={`calc(100% - ${offsetTop}px)`}
+      height={`${contentHeight}px`}
       borderWidth={1}
       borderColor="gray.200"
     >
-      {!!offsetTop && (
+      {!!contentHeight && (
         <ReactFlow
           nodes={nodes}
           edges={edges}
diff --git a/airflow/www/static/js/dag/details/taskInstance/Logs/LogBlock.tsx 
b/airflow/www/static/js/dag/details/taskInstance/Logs/LogBlock.tsx
index e714c9c1eff..4b3e62f095f 100644
--- a/airflow/www/static/js/dag/details/taskInstance/Logs/LogBlock.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/Logs/LogBlock.tsx
@@ -19,7 +19,7 @@
 
 import React, { useRef, useEffect, useState } from "react";
 import { Code } from "@chakra-ui/react";
-import { useOffsetTop } from "src/utils";
+import { useContentHeight } from "src/utils";
 
 interface Props {
   parsedLogs: string;
@@ -37,7 +37,7 @@ const LogBlock = ({
   const [autoScroll, setAutoScroll] = useState(true);
 
   const logBoxRef = useRef<HTMLPreElement>(null);
-  const offsetTop = useOffsetTop(logBoxRef);
+  const contentHeight = useContentHeight(logBoxRef);
 
   const scrollToBottom = () => {
     if (logBoxRef.current) {
@@ -47,13 +47,13 @@ const LogBlock = ({
 
   useEffect(() => {
     // Always scroll to bottom when wrap or tryNumber change
-    if (offsetTop) scrollToBottom();
-  }, [wrap, tryNumber, offsetTop]);
+    if (contentHeight) scrollToBottom();
+  }, [wrap, tryNumber, contentHeight]);
 
   useEffect(() => {
     // When logs change, only scroll if autoScroll is enabled
-    if (autoScroll && offsetTop) scrollToBottom();
-  }, [parsedLogs, autoScroll, offsetTop]);
+    if (autoScroll && contentHeight) scrollToBottom();
+  }, [parsedLogs, autoScroll, contentHeight]);
 
   const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
     if (e.currentTarget) {
@@ -103,7 +103,7 @@ const LogBlock = ({
       ref={logBoxRef}
       onScroll={onScroll}
       onClick={onClick}
-      maxHeight={`calc(100% - ${offsetTop}px)`}
+      maxHeight={`${contentHeight}px`}
       overflowY="auto"
       p={3}
       display="block"
diff --git a/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx 
b/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
index d126a113fca..af70b707624 100644
--- a/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/MappedInstances.tsx
@@ -27,7 +27,7 @@ import { useMappedInstances } from "src/api";
 import { StatusWithNotes } from "src/dag/StatusBox";
 import { Table, CellProps } from "src/components/Table";
 import Time from "src/components/Time";
-import { useOffsetTop } from "src/utils";
+import { useContentHeight } from "src/utils";
 
 interface Props {
   dagId: string;
@@ -38,7 +38,7 @@ interface Props {
 
 const MappedInstances = ({ dagId, runId, taskId, onRowClicked }: Props) => {
   const mappedTasksRef = useRef<HTMLDivElement>(null);
-  const offsetTop = useOffsetTop(mappedTasksRef);
+  const contentHeight = useContentHeight(mappedTasksRef);
   const limit = 25;
   const [offset, setOffset] = useState(0);
   const [sortBy, setSortBy] = useState<SortingRule<object>[]>([]);
@@ -125,11 +125,7 @@ const MappedInstances = ({ dagId, runId, taskId, 
onRowClicked }: Props) => {
   );
 
   return (
-    <Box
-      ref={mappedTasksRef}
-      maxHeight={`calc(100% - ${offsetTop}px)`}
-      overflowY="auto"
-    >
+    <Box ref={mappedTasksRef} maxHeight={`${contentHeight}px`} 
overflowY="auto">
       <Table
         data={data}
         columns={columns}
diff --git a/airflow/www/static/js/dag/details/taskInstance/RenderedK8s.tsx 
b/airflow/www/static/js/dag/details/taskInstance/RenderedK8s.tsx
index 459d191edd1..633a44a476c 100644
--- a/airflow/www/static/js/dag/details/taskInstance/RenderedK8s.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/RenderedK8s.tsx
@@ -21,7 +21,7 @@ import React, { useRef } from "react";
 import { Code } from "@chakra-ui/react";
 import YAML from "json-to-pretty-yaml";
 
-import { getMetaValue, useOffsetTop } from "src/utils";
+import { getMetaValue, useContentHeight } from "src/utils";
 
 import useSelection from "src/dag/useSelection";
 import { useRenderedK8s } from "src/api";
@@ -36,17 +36,12 @@ const RenderedK8s = () => {
   const { data: renderedK8s } = useRenderedK8s(runId, taskId, mapIndex);
 
   const k8sRef = useRef<HTMLPreElement>(null);
-  const offsetTop = useOffsetTop(k8sRef);
+  const contentHeight = useContentHeight(k8sRef);
 
   if (!isK8sExecutor || !runId || !taskId) return null;
 
   return (
-    <Code
-      mt={3}
-      ref={k8sRef}
-      maxHeight={`calc(100% - ${offsetTop}px)`}
-      overflowY="auto"
-    >
+    <Code mt={3} ref={k8sRef} maxHeight={`${contentHeight}px`} 
overflowY="auto">
       <pre>{YAML.stringify(renderedK8s)}</pre>
     </Code>
   );
diff --git a/airflow/www/static/js/dag/details/taskInstance/Xcom/index.tsx 
b/airflow/www/static/js/dag/details/taskInstance/Xcom/index.tsx
index 00892fab8bd..0b51293edab 100644
--- a/airflow/www/static/js/dag/details/taskInstance/Xcom/index.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/Xcom/index.tsx
@@ -31,7 +31,7 @@ import {
 
 import type { Dag, DagRun, TaskInstance } from "src/types";
 import { useTaskXcomCollection } from "src/api";
-import { useOffsetTop } from "src/utils";
+import { useContentHeight } from "src/utils";
 import ErrorAlert from "src/components/ErrorAlert";
 
 import XcomEntry from "./XcomEntry";
@@ -52,7 +52,7 @@ const XcomCollection = ({
   tryNumber,
 }: Props) => {
   const taskXcomRef = useRef<HTMLDivElement>(null);
-  const offsetTop = useOffsetTop(taskXcomRef);
+  const contentHeight = useContentHeight(taskXcomRef);
 
   const {
     data: xcomCollection,
@@ -70,7 +70,7 @@ const XcomCollection = ({
     <Box
       ref={taskXcomRef}
       height="100%"
-      maxHeight={`calc(100% - ${offsetTop}px)`}
+      maxHeight={`${contentHeight}px`}
       overflowY="auto"
     >
       {isLoading && <Spinner size="xl" thickness="4px" speed="0.65s" />}
diff --git a/airflow/www/static/js/dag/details/taskInstance/index.tsx 
b/airflow/www/static/js/dag/details/taskInstance/index.tsx
index 74f317867ae..7ef972ee808 100644
--- a/airflow/www/static/js/dag/details/taskInstance/index.tsx
+++ b/airflow/www/static/js/dag/details/taskInstance/index.tsx
@@ -21,7 +21,7 @@ import React, { useRef } from "react";
 import { Box } from "@chakra-ui/react";
 
 import { useGridData, useTaskInstance } from "src/api";
-import { getMetaValue, getTask, useOffsetTop } from "src/utils";
+import { getMetaValue, getTask, useContentHeight } from "src/utils";
 import type { DagRun, TaskInstance as GridTaskInstance } from "src/types";
 import NotesAccordion from "src/dag/details/NotesAccordion";
 
@@ -43,7 +43,7 @@ interface Props {
 
 const TaskInstance = ({ taskId, runId, mapIndex }: Props) => {
   const taskInstanceRef = useRef<HTMLDivElement>(null);
-  const offsetTop = useOffsetTop(taskInstanceRef);
+  const contentHeight = useContentHeight(taskInstanceRef);
   const isMapIndexDefined = !(mapIndex === undefined);
   const {
     data: { dagRuns, groups },
@@ -79,8 +79,7 @@ const TaskInstance = ({ taskId, runId, mapIndex }: Props) => {
   return (
     <Box
       py="4px"
-      height="100%"
-      maxHeight={`calc(100% - ${offsetTop}px)`}
+      height={`${contentHeight}px`}
       ref={taskInstanceRef}
       overflowY="auto"
     >
diff --git a/airflow/www/static/js/utils/index.ts 
b/airflow/www/static/js/utils/index.ts
index 23862e13712..af10a64a2b8 100644
--- a/airflow/www/static/js/utils/index.ts
+++ b/airflow/www/static/js/utils/index.ts
@@ -21,6 +21,7 @@ import Color from "color";
 import type { DagRun, RunOrdering, Task, TaskInstance } from "src/types";
 
 import { LogLevel } from "src/dag/details/taskInstance/Logs/utils";
+import useContentHeight from "./useContentHeight";
 import useOffsetTop from "./useOffsetTop";
 
 // Delay in ms for various hover actions
@@ -232,6 +233,7 @@ export {
   getTaskSummary,
   getDagRunLabel,
   getStatusBackgroundColor,
+  useContentHeight,
   useOffsetTop,
   toSentenceCase,
   highlightByKeywords,
diff --git a/airflow/www/static/js/utils/useContentHeight.ts 
b/airflow/www/static/js/utils/useContentHeight.ts
new file mode 100644
index 00000000000..fbfcb8b20ef
--- /dev/null
+++ b/airflow/www/static/js/utils/useContentHeight.ts
@@ -0,0 +1,69 @@
+/*!
+ * 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 React, { useLayoutEffect, useState } from "react";
+
+// Vertical space available to a scroll container, measured down to the bottom 
of the nearest
+// min-height-constrained ancestor — the grid's Main box, which has a 
min-height floor. Anchoring to
+// that box (rather than the viewport) lets the content grow to fill the 
floored area even when the
+// box is taller than the viewport and the page scrolls, instead of shrinking 
to the fold and
+// leaving a blank strip below. When there is no such ancestor (the Main box 
measuring itself), it
+// falls back to the viewport minus the body's bottom padding (the Flask 
footer reserve). Either way
+// this avoids the `height: 100%` cascade, which over-claims because 
<Tabs>/<TabPanel> have sibling
+// chrome (tab headers) stacked above the panel — that cascade is what 
truncated the details tabs.
+const useContentHeight = (contentRef: React.RefObject<HTMLElement>) => {
+  const [height, setHeight] = useState(0);
+
+  useLayoutEffect(() => {
+    const update = () => {
+      const element = contentRef.current;
+      if (!element) return;
+      let container = element.parentElement;
+      while (
+        container &&
+        !(parseFloat(getComputedStyle(container).minHeight) > 0)
+      ) {
+        container = container.parentElement;
+      }
+      const bottom = container
+        ? container.getBoundingClientRect().bottom
+        : window.innerHeight -
+          (parseFloat(getComputedStyle(document.body).paddingBottom) || 0);
+      const available = Math.max(
+        bottom - element.getBoundingClientRect().top,
+        0,
+      );
+      // Guard against re-setting the same value (avoids ResizeObserver 
feedback loops).
+      setHeight((previous) => (previous === available ? previous : available));
+    };
+
+    update();
+    window.addEventListener("resize", update);
+    const observer = new ResizeObserver(update);
+    observer.observe(document.body);
+    return () => {
+      window.removeEventListener("resize", update);
+      observer.disconnect();
+    };
+  }, [contentRef]);
+
+  return height;
+};
+
+export default useContentHeight;

Reply via email to