feat(v2 Containers): scrollable containers (#6629)

This commit is contained in:
Bergasha authored and GitHub committed 2026-08-22 09:24:52 +02:00
1 parent 011ce8e5a3
commit 7591f7e313
9 files changed
+89 -4

No files matched your search

@@ -46,6 +46,11 @@ export const ContainerEditModal = createModal<ModalProps>(({ actions, innerProps
description={tSection("option.showOpenAll.description")}
{...form.getInputProps("showOpenAll", { type: "checkbox" })}
/>
<Switch
label={tSection("option.scrollable.label")}
description={tSection("option.scrollable.description")}
{...form.getInputProps("scrollable", { type: "checkbox" })}
/>
<TextMultiSelect
label={tSection("container.option.customCssClasses.label")}
{...form.getInputProps("customCssClasses")}
@@ -296,6 +296,21 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
top: -0.625rem;
left: -0.625rem;
cursor: nwse-resize;
/* Leaves a thin draggable border around the corner instead of a solid 44x44 hit area,
so it doesn't sit on top of the container menu button anchored in this same corner. */
clip-path: polygon(
evenodd,
0 0,
100% 0,
100% 100%,
0 100%,
0 0,
4px 4px,
4px 40px,
40px 40px,
40px 4px,
4px 4px
);
}
@media (prefers-reduced-motion: reduce) {
@@ -91,7 +91,17 @@ export const GridPreviewLayer = ({
const viewport = grid.parentElement;
if (viewport?.hasAttribute("data-section-id")) {
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
// This grows the viewport to match the inner grid while a drag needs extra room to show a
// drop target (maxRowCount === null - a "main"/rail section without a fixed cap). A capped
// section (e.g. a scrollable container, maxRowCount !== null) must never have this set: it
// isn't just a temporary default here, this effect reruns on every board mutation - so a
// stray value would silently blow the cap open for the rest of the session, well after any
// drag ended, since nothing else would ever clear it back out.
if (maxRowCount === null) {
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
} else {
viewport.style.removeProperty("--board-grid-drag-height");
}
}
}, [
entryElements,
@@ -3,6 +3,28 @@
overflow: visible;
}
/* Chained with .viewport (rather than a standalone .scrollableViewport rule) so this always
wins on specificity, not source order. GridEditor loads as a separate async chunk in edit
mode, and a board mutation can trigger its stylesheet to re-register - at equal specificity,
that flips which of these two rules the cascade picks, silently un-capping the container even
though its height (set inline, see section-grid.tsx) stays correct throughout. */
.viewport.scrollableViewport {
overflow-x: hidden;
overflow-y: auto;
/* --board-grid-drag-height inherits down the DOM tree like any custom property. A capped
section's own GridPreviewLayer effect correctly avoids setting it on itself (see
grid-preview-layer.tsx), but that does nothing to stop it inheriting the value an
*uncapped* ancestor section (e.g. the board's root section) legitimately sets on itself
while growing during a drag happening anywhere else on the board - a nested scrollable
container 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, which only kicks in when the property is entirely unset, not merely
inherited. Reset it here so this element 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 */
--board-grid-drag-height: initial;
}
.staticGrid,
.editorGrid {
position: relative;
@@ -130,8 +130,13 @@ export const SectionGrid = ({
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
// 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 logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
const viewportHeight = getLogicalTrackSize(viewportRowCount);
// 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);
@@ -190,6 +195,18 @@ export const SectionGrid = ({
section,
]);
const viewportRef = useRef<HTMLDivElement>(null);
const previousItemIdsRef = useRef<Set<string> | null>(null);
useEffect(() => {
const currentIds = new Set([...items, ...innerSections].map((item) => item.id));
const previousIds = previousItemIdsRef.current;
const hasNewItem = previousIds !== null && [...currentIds].some((id) => !previousIds.has(id));
previousItemIdsRef.current = currentIds;
if (hasNewItem && isScrollableContainer) {
viewportRef.current?.scrollTo({ top: viewportRef.current.scrollHeight, behavior: "smooth" });
}
}, [innerSections, isScrollableContainer, items]);
return (
<SectionProvider
value={{
@@ -206,18 +223,20 @@ export const SectionGrid = ({
}}
>
<Box
ref={viewportRef}
{...canvasAttributes}
className={combineClasses(classes.viewport, className)}
className={combineClasses(classes.viewport, isScrollableContainer && classes.scrollableViewport, className)}
style={
{
width: logicalWidth,
height: `var(--board-grid-drag-height, ${logicalHeight}px)`,
height: `var(--board-grid-drag-height, ${viewportHeight}px)`,
"--board-item-radius": `var(--mantine-radius-${board.itemRadius})`,
} as CSSProperties
}
data-section-id={section.id}
data-section-kind={section.kind}
data-rail-placement={railPlacement}
data-scrollable={isScrollableContainer ? "true" : undefined}
data-grid-interaction-disabled={isEditMode && isInteractionDisabled ? "true" : undefined}
onPointerDownCapture={expandCollapsedSectionsForPointerEdit}
onKeyDownCapture={expandCollapsedSectionsForKeyboardEdit}
@@ -296,6 +315,11 @@ const getContainerMinimumSizes = (board: ReturnType<typeof useRequiredBoard>, la
if (visiting.has(sectionId)) return { width: 1, height: 1 };
visiting.add(sectionId);
const section = board.sections.find((candidate) => candidate.id === sectionId);
// A scrollable container isn't forced to grow with its content - it scrolls internally instead,
// so it shouldn't have a content-derived height floor imposed by its parent's grid.
const isScrollable = section?.kind === "container" && section.options.scrollable;
const itemBounds = directItemsBySectionId.get(sectionId) ?? [];
const sectionBounds = (directSectionsBySectionId.get(sectionId) ?? []).map(({ id, placement }) => {
const minimum = resolve(id);
@@ -308,7 +332,7 @@ const getContainerMinimumSizes = (board: ReturnType<typeof useRequiredBoard>, la
const children = [...itemBounds, ...sectionBounds];
const minimum = {
width: Math.max(1, ...children.map((child) => child.xOffset + child.width)),
height: Math.max(1, ...children.map((child) => child.yOffset + child.height)),
height: isScrollable ? 1 : Math.max(1, ...children.map((child) => child.yOffset + child.height)),
};
visiting.delete(sectionId);
minimumBySectionId.set(sectionId, minimum);
@@ -1396,6 +1396,7 @@ describe("saveBoard should save full board", () => {
showLabel: false,
collapsible: false,
showOpenAll: false,
scrollable: false,
});
expect(
await db.query.sectionLayouts.findFirst({
+4
View File
@@ -1943,6 +1943,10 @@
"showOpenAll": {
"label": "Show open-all action",
"description": "Show an action that opens every app in this container in new tabs."
},
"scrollable": {
"label": "Scrollable",
"description": "Keep this container at its set size and scroll its contents instead of growing to fit them."
}
},
"action": {
+2
View File
@@ -75,6 +75,7 @@ describe("section behavior validation", () => {
showLabel: false,
collapsible: true,
showOpenAll: true,
scrollable: true,
},
layouts: [],
});
@@ -88,6 +89,7 @@ describe("section behavior validation", () => {
showLabel: false,
collapsible: true,
showOpenAll: true,
scrollable: true,
},
});
});
+2
View File
@@ -61,6 +61,7 @@ export const containerSectionOptionsDefaults = {
showLabel: true,
collapsible: false,
showOpenAll: false,
scrollable: false,
} as const;
export const containerSectionOptionsSchema = z
@@ -71,6 +72,7 @@ export const containerSectionOptionsSchema = z
showLabel: z.boolean().default(containerSectionOptionsDefaults.showLabel),
collapsible: z.boolean().default(containerSectionOptionsDefaults.collapsible),
showOpenAll: z.boolean().default(containerSectionOptionsDefaults.showOpenAll),
scrollable: z.boolean().default(containerSectionOptionsDefaults.scrollable),
})
.default(containerSectionOptionsDefaults);