fix(board): refine container spacing and collapse layout (#6833)

This commit is contained in:
Thomas authored and GitHub committed 2026-09-12 15:23:50 +02:00
1 parent 9bc3802b3b
commit ff2b589380
5 files changed
+82 -57

No files matched your search

@@ -11,6 +11,7 @@ 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 { useSectionContext } from "./section-context";
import { useOpenSectionApps } from "./use-open-section-apps";
import classes from "./item.module.css";
@@ -25,6 +26,7 @@ interface Props {
export const BoardContainerSection = ({ section }: Props) => {
const board = useRequiredBoard();
const { section: parentSection } = useSectionContext();
const [isEditMode] = useEditMode();
const t = useI18n("section.container");
const tSection = useI18n("section");
@@ -39,8 +41,26 @@ export const BoardContainerSection = ({ section }: Props) => {
});
const label = options.title.trim() || t("untitled");
const contentId = `board-container-${section.id}-content`;
const labelLeft = 8;
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
let labelLeft = 8;
let labelRight = 8;
if (isEditMode) {
// Match the root/nested menu offsets in BoardContainerMenu, plus its width and gap.
labelLeft = 36;
if (parentSection.kind === "container") labelLeft = 68;
} else if (options.showOpenAll) {
labelRight = 40;
}
// Expanded controls sit on the border like the ordinary label, without reserving a header row.
const toggleLayout = isVisuallyCollapsed
? { top: 0, left: 0, w: "100%", h: "100%", maw: "100%" }
: {
top: "calc(var(--mantine-spacing-xs) * -1)",
left: labelLeft,
w: "auto",
h: 20,
maw: `calc(100% - ${labelLeft + labelRight}px)`,
};
const toggleIcon = isVisuallyCollapsed ? <IconChevronDown size={16} /> : <IconChevronUp size={16} />;
return (
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
@@ -68,24 +88,14 @@ export const BoardContainerSection = ({ section }: Props) => {
<Button
className={classes.containerToggle}
pos="absolute"
top={0}
left={0}
w="100%"
h={isVisuallyCollapsed ? "100%" : 24}
px={12}
pe={isEditMode ? 48 : options.showOpenAll ? 40 : 12}
{...toggleLayout}
px={6}
ps={isVisuallyCollapsed ? labelLeft : 6}
pe={isVisuallyCollapsed ? labelRight : 6}
radius="sm"
variant="default"
justify={options.showLabel ? "flex-start" : "center"}
leftSection={
options.showLabel ? (
isVisuallyCollapsed ? (
<IconChevronDown size={16} />
) : (
<IconChevronUp size={16} />
)
) : undefined
}
leftSection={options.showLabel && toggleIcon}
onClick={toggle}
aria-expanded={!isVisuallyCollapsed}
aria-controls={contentId}
@@ -94,13 +104,7 @@ export const BoardContainerSection = ({ section }: Props) => {
data-board-container-label
title={options.showLabel ? label : undefined}
>
{options.showLabel ? (
label
) : isVisuallyCollapsed ? (
<IconChevronDown size={16} />
) : (
<IconChevronUp size={16} />
)}
{options.showLabel ? label : toggleIcon}
</Button>
)}
{!isVisuallyCollapsed && !options.collapsible && options.showLabel && options.title && (
@@ -133,6 +133,21 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
background: color-mix(in srgb, var(--mantine-primary-color-filled) 10%, transparent);
}
.board-grid-placeholder[data-grid-placeholder-type="section"] {
clip-path: none;
background: none;
border: 0;
}
.board-grid-placeholder[data-grid-placeholder-type="section"]::after {
position: absolute;
inset: var(--board-container-inset, 0px);
content: "";
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
border: 2px dashed var(--mantine-primary-color-filled);
border-radius: inherit;
}
.board-grid-resize-outline {
z-index: 1002;
clip-path: inset(var(--board-grid-item-inset, 0px));
@@ -136,6 +136,7 @@ export const GridPreviewLayer = ({
style={getLogicalItemStyle(targetPlacement)}
data-grid-placeholder-for={interaction?.activeId}
data-grid-placeholder-mode={interaction?.mode}
data-grid-placeholder-type={targetPlacement.type}
data-grid-x={targetPlacement.x}
data-grid-y={targetPlacement.y}
data-grid-w={targetPlacement.w}
@@ -10,9 +10,9 @@
while growing during a drag happening anywhere else on the board - any container (not just
a scrollable one - a non-scrollable, e.g. collapsible, container is just as capped) sits
inside that section's DOM tree, so the stray value cascades straight through and silently
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx (including
its collapsibleHeaderInset adjustment), which only kicks in when the property is entirely
unset, not merely inherited. Reset it here so every container can never inherit a stray
defeats the `var(--board-grid-drag-height, ...px)` fallback in section-grid.tsx, which only
kicks in when the property is entirely unset, not merely inherited. Reset it here so every
container can never inherit a stray
value from anywhere above it, no matter what any ancestor's own drag state is doing.
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables#custom_properties_and_inheritance */
.viewport[data-section-kind="container"] {
@@ -62,9 +62,9 @@
overflow: visible;
}
/* Containers provide their own inner item gap, so their card must keep the full grid aspect ratio. */
/* SectionGrid fits its content to the same inset in both view and edit mode. */
.staticItem[data-type="section"] > .contentMount {
inset: 0;
inset: var(--board-container-inset, 0px);
}
.staticItem[data-type="item"] > .contentMount {
@@ -1,7 +1,7 @@
"use client";
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createContext, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { Box } from "@mantine/core";
import combineClasses from "clsx";
@@ -29,6 +29,9 @@ import { useSectionItems } from "../use-section-items";
import { useBoardGridPortalHost } from "./grid-portal-host";
import classes from "./section-grid.module.css";
const GridContentScaleContext = createContext(1);
const CONTAINER_CARD_INSET = 5;
interface SectionGridProps {
section: Exclude<Section, { kind: "container" }> | ContainerSectionItem;
columnCount: number;
@@ -50,6 +53,7 @@ export const SectionGrid = ({
}: SectionGridProps) => {
const [isEditMode] = useEditMode();
const canvasScale = useBoardCanvasScale();
const parentContentScale = useContext(GridContentScaleContext);
const editorRuntimeStatus = useGridEditorRuntimeStatus();
const editorRegistry = useGridEditorRegistry();
const editorHostRef = useRef<HTMLDivElement>(null);
@@ -143,36 +147,37 @@ export const SectionGrid = ({
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount =
viewportRowCountOverride ?? (isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount);
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
// The collapsible label keeps a fixed physical height while the grid uses logical canvas pixels.
// Convert that height so the label reserves the same space at every board scale.
const collapsibleHeaderInset =
section.kind === "container" && section.options.collapsible ? CONTAINER_HEADER_HEIGHT / effectiveCanvasScale : 0;
const parentScale = canvasScale * parentContentScale;
const effectiveCanvasScale = Number.isFinite(parentScale) && parentScale > 0 ? parentScale : 1;
// Match the card's inset without changing its persisted grid footprint. Include ancestor
// container zoom so equally sized nested containers keep distinct borders at every depth.
let outerCardInset = 0;
if (section.kind === "container") {
outerCardInset = (2 * CONTAINER_CARD_INSET) / effectiveCanvasScale;
}
const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount);
const logicalWidth = fullGridWidth;
const viewportHeight = Math.max(1, getLogicalGridSize(viewportRowCount) - collapsibleHeaderInset);
// For a scrollable container, rowCount covers *all* content rows, not just the visible card -
// fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
// grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
// viewport needs and clipping the bottom of the visible rows. viewportHeight/viewportRowCount
// describe the real visible card size in both cases (they equal the non-scrollable values when
// rowCount and viewportRowCount are the same), so use those instead.
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
// A collapsible container reserves a fixed-height label above the square-cell grid. Keep the
// nested content uniformly scaled within the remaining height so labels and icons retain their
// proportions; ordinary containers use the original unscaled, edge-aligned grid geometry.
const containerContentScale =
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
: 1;
const logicalWidth = Math.max(1, fullGridWidth - outerCardInset);
const viewportHeight = Math.max(1, fullViewportHeight - outerCardInset);
// Fit square cells into the inset card. Scrollable grids use the visible row count,
// not their full content height. Collapse controls never change this geometry.
let containerContentScale = 1;
if (section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0) {
containerContentScale = Math.max(
0.01,
Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1),
);
}
const contentScale = parentContentScale * containerContentScale;
const effectiveContentScale = effectiveCanvasScale * containerContentScale;
// Compute the combined value in JS rather than a CSS calc() referencing the existing
// --board-canvas-ui-scale: custom properties declared on the *same* element don't have a
// sequential/temporal order the way normal variables do, so any calc() on this element that
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
// is a circular reference - CSS invalidates the whole group rather than using "the old value",
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
const combinedUiScale = calculateBoardUiScale(canvasScale) / contentScale;
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -268,9 +273,9 @@ export const SectionGrid = ({
{
width: logicalWidth,
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
marginTop: collapsibleHeaderInset || undefined,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
"--board-grid-content-scale": containerContentScale,
"--board-container-inset": `${CONTAINER_CARD_INSET / effectiveContentScale}px`,
} as CSSProperties
}
data-section-id={section.id}
@@ -289,14 +294,17 @@ export const SectionGrid = ({
height: fullGridHeight,
zoom: containerContentScale,
margin: containerContentScale < 1 ? "0 auto" : undefined,
...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}),
"--board-canvas-inverse-scale": 1 / effectiveContentScale,
"--board-canvas-ui-scale": combinedUiScale,
} as CSSProperties
}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
>
<SectionContent />
<GridContentScaleContext.Provider value={contentScale}>
<SectionContent />
</GridContentScaleContext.Provider>
</Box>
<div ref={editorHostRef} className={classes.editorPortalHost} />
</Box>
@@ -304,9 +312,6 @@ export const SectionGrid = ({
);
};
// Matches the collapsible container toggle's h={24} in container-section.tsx.
const CONTAINER_HEADER_HEIGHT = 24;
const INTERACTIVE_GRID_SELECTOR =
'a,button,input,textarea,select,option,[contenteditable="true"],[role="button"],[data-grid-no-drag]';
const EDIT_ACTIVATION_KEYS = new Set(["Enter", " ", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"]);