fix(board): fix container void/overflow gap across scrollable and collapse states (#6750)

A container's outer grid cell and its own inner content grid are sized by
two independent calculations that only agree when the container is
scrollable and expanded. When collapsed, or when non-scrollable content
needs more rows than declared, the inner grid keeps rendering at its old/
content-driven size while the outer cell shrinks or stays fixed, leaving
an invisible-but-space-occupying box that inflates the board's scroll
height into a visible empty gap below the container.

Pass the outer instance's collapsed row count down as a
viewportRowCountOverride so the inner grid matches it exactly when
collapsed, and grow the outer placement's height to the already-computed
content minimum (getContainerMinimumSizes) for non-scrollable containers
so it stays in sync with the inner grid's own growth.
This commit is contained in:
Bergasha authored and GitHub committed 2026-08-31 15:51:17 +10:00
1 parent 5e23659d99
commit feea3512d0
2 files changed
+17 -10

No files matched your search

@@ -8,6 +8,7 @@ import { useEditMode } from "@homarr/boards/edit-mode";
import { useI18n } from "@homarr/translation/client";
import type { ContainerSectionItem } from "~/app/[locale]/boards/_types";
import { COLLAPSED_SECTION_ROW_COUNT } from "~/components/board/layout";
import { SectionGrid } from "./grid/section-grid";
import { useSectionCollapse } from "./section-collapse";
import { useOpenSectionApps } from "./use-open-section-apps";
@@ -149,7 +150,13 @@ export const BoardContainerSection = ({ section }: Props) => {
aria-hidden={isVisuallyCollapsed}
inert={isVisuallyCollapsed}
>
<SectionGrid section={section} columnCount={section.width} requestedRowCount={section.height} label={label} />
<SectionGrid
section={section}
columnCount={section.width}
requestedRowCount={section.height}
viewportRowCountOverride={isVisuallyCollapsed ? COLLAPSED_SECTION_ROW_COUNT : undefined}
label={label}
/>
</Box>
</Card>
{isEditMode && <BoardContainerMenu section={section} />}
@@ -33,6 +33,7 @@ interface SectionGridProps {
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
columnCount: number;
requestedRowCount?: number;
viewportRowCountOverride?: number;
label: string;
railPlacement?: "main" | "left" | "right";
className?: string;
@@ -42,6 +43,7 @@ export const SectionGrid = ({
section,
columnCount,
requestedRowCount = 0,
viewportRowCountOverride,
label,
railPlacement = "main",
className,
@@ -67,6 +69,8 @@ export const SectionGrid = ({
() =>
[...items, ...innerSections].map((item): SectionGridPlacement => {
const minimum = item.type === "section" ? minimumBySectionId.get(item.id) : undefined;
const isNonScrollableContainer = item.type === "section" && !item.options.scrollable;
const height = isNonScrollableContainer && minimum ? Math.max(item.height, minimum.height) : item.height;
return normalizeGridPlacement(
{
id: item.id,
@@ -74,7 +78,7 @@ export const SectionGrid = ({
x: item.xOffset,
y: item.yOffset,
w: item.width,
h: item.height,
h: height,
minW: minimum?.width,
minH: minimum?.height,
},
@@ -137,7 +141,8 @@ export const SectionGrid = ({
// A scrollable container isn't forced to grow with its content - it scrolls internally instead
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
const viewportRowCount =
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
// A container's own visible card is inset from its allocated board cell by the board's
// standard per-item gap (see the base, non-container `.staticItem[data-type="item"] >
// .contentMount` rule in section-grid.module.css - a container is placed as an "item" like
@@ -159,14 +164,9 @@ export const SectionGrid = ({
// all, the header bar covers the first row of the container's content instead of sitting
// above it. Only the vertical axis is affected - the toggle spans the full width already.
const collapsibleHeaderInset =
section.kind === "container" && section.options.collapsible
? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale
: 0;
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
const viewportHeight = Math.max(
1,
getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset,
);
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - outerCardInset - collapsibleHeaderInset);
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
// deliberately smaller by outerCardInset to match the container's actual visible card size.