fix: stabilize rail and resize interactions

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:12 +02:00
1 parent d635e4d80f
commit ab012b4a32
5 files changed
+101 -20

No files matched your search

@@ -21,10 +21,7 @@
height: var(--board-gutter-viewport-height);
padding-top: 24px;
margin-top: -24px;
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
overflow: clip;
}
.gutter[data-board-editing="true"] {
@@ -116,11 +116,6 @@ body[data-board-grid-interacting="resize"] .board-grid-entry[data-dnd-active="tr
pointer-events: none;
}
.board-grid-resize-outline[data-grid-resize-valid="false"] {
background: color-mix(in srgb, var(--mantine-color-red-filled) 8%, transparent);
border-color: var(--mantine-color-red-filled);
}
.board-grid-editor[data-dnd-drop-target="true"] {
outline: 2px solid color-mix(in srgb, var(--mantine-primary-color-filled) 60%, transparent);
outline-offset: 4px;
@@ -59,6 +59,7 @@ interface GridEditorProps {
sectionId: string;
columnCount: number;
rowCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
className: string;
}
@@ -707,7 +708,14 @@ const GridCollisionSynchronizer = ({ interaction }: { interaction: GridInteracti
};
/** A section-local dnd-kit canvas registered with the board transaction. */
export default function GridEditor({ sectionId, columnCount, rowCount, placements, className }: GridEditorProps) {
export default function GridEditor({
sectionId,
columnCount,
rowCount,
maxRowCount,
placements,
className,
}: GridEditorProps) {
const t = useI18n();
const { section, items, innerSections } = useSectionContext();
const { interaction, registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditor();
@@ -744,7 +752,7 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
interaction && (interaction.sourceGridId === sectionId || interaction.targetGridId === sectionId);
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? renderPlacements);
const renderedRowCount =
section.kind !== "container" && participatesInInteraction
maxRowCount === null && participatesInInteraction
? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0)
: rowCount;
@@ -775,14 +783,14 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
return registerGrid({
id: sectionId,
columnCount,
maxRowCount: section.kind === "container" ? rowCount : null,
maxRowCount,
parentGridId,
ownerPlacementId,
placements,
depth,
element,
});
}, [columnCount, depth, ownerPlacementId, parentGridId, placements, registerGrid, rowCount, section.kind, sectionId]);
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]);
useLayoutEffect(() => {
const viewport = gridRef.current?.parentElement;
@@ -824,7 +832,7 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
placement={placement}
label={label}
columnCount={columnCount}
maxRowCount={section.kind === "container" ? rowCount : null}
maxRowCount={maxRowCount}
/>
);
})}
@@ -1044,6 +1052,7 @@ const GridResizeHandles = ({
y: number;
direction: GridResizeDirection;
visualScale: { x: number; y: number };
placement: SectionGridPlacement;
} | null>(null);
const start = (direction: GridResizeDirection) =>
@@ -1077,7 +1086,14 @@ const GridResizeHandles = ({
cancelResize();
return;
}
pointerStartRef.current = { id: event.pointerId, x: event.clientX, y: event.clientY, direction, visualScale };
pointerStartRef.current = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
direction,
visualScale,
placement: { ...placement },
};
};
const handlePointerMove = (event: ReactPointerEvent<HTMLSpanElement>) => {
@@ -1095,14 +1111,14 @@ const GridResizeHandles = ({
const valid = update(started.direction, snappedDelta.x, snappedDelta.y);
setContinuousResize({
placement: getContinuousResizePlacement({
placement,
placement: started.placement,
direction: started.direction,
deltaColumns: continuousDelta.x,
deltaRows: continuousDelta.y,
columnCount,
maxRowCount,
minWidth: placement.minW ?? 1,
minHeight: placement.minH ?? 1,
minWidth: started.placement.minW ?? 1,
minHeight: started.placement.minH ?? 1,
}),
valid,
});
@@ -150,6 +150,7 @@ export const SectionGrid = ({
const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const maxRowCount = section.kind === "container" || railPlacement !== "main" ? rowCount : null;
const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
const canvasAttributes = isEditMode
@@ -164,7 +165,7 @@ export const SectionGrid = ({
innerSections: displayedInnerSections,
integrations,
columnCount,
maxRowCount: section.kind === "container" ? rowCount : null,
maxRowCount,
announce,
}}
>
@@ -182,6 +183,7 @@ export const SectionGrid = ({
sectionId={section.id}
columnCount={columnCount}
rowCount={rowCount}
maxRowCount={maxRowCount}
placements={displayPlacements}
className={combineClasses("board-grid-editor", classes.editorGrid)}
/>
+72 -1
View File
@@ -95,7 +95,8 @@ describe("Board grid", () => {
await expect(rail).toBeVisible();
await expect(rail).toHaveAttribute("data-board-gutter", "left");
await expect(railRootSection).toHaveAttribute("data-rail-placement", "left");
await expect(rail).toHaveCSS("overflow-x", "hidden");
await expect(rail).toHaveCSS("overflow-x", "clip");
await expect(rail).toHaveCSS("overflow-y", "clip");
const initialRailBox = await expectBoundingBoxAsync(rail);
const initialRailRootBox = await expectBoundingBoxAsync(railRootSection);
expect(initialRailRootBox.width).toBeCloseTo(initialRailBox.width, 1);
@@ -240,6 +241,7 @@ describe("Board grid", () => {
const viewMainSectionBox = await expectBoundingBoxAsync(mainSection);
const viewFirstItemBox = await expectBoundingBoxAsync(logicalTile);
const viewScrollY = await page.evaluate(() => window.scrollY);
runtimeResources.enterEditMode();
await editToggle.click();
await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3);
@@ -285,6 +287,39 @@ describe("Board grid", () => {
await page.mouse.up();
await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(railSnapshot);
await rail.locator('[data-e2e-rail-overflow-fixture="true"]').evaluate((element) => element.remove());
await rail.evaluate((element) => {
const overflowFixture = document.createElement("div");
overflowFixture.dataset.e2eRailOverflowFixture = "true";
overflowFixture.style.position = "absolute";
overflowFixture.style.insetBlockStart = "100%";
overflowFixture.style.width = "1px";
overflowFixture.style.height = "500px";
overflowFixture.style.pointerEvents = "none";
element.appendChild(overflowFixture);
});
await expect
.poll(async () => await rail.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(0);
const fixedRailGridHeight = (await expectBoundingBoxAsync(railGrid)).height;
const fixedRailHeight = (await expectBoundingBoxAsync(rail)).height;
const verticalRailItemBox = await expectBoundingBoxAsync(railItem);
await page.mouse.move(
verticalRailItemBox.x + verticalRailItemBox.width / 2,
verticalRailItemBox.y + verticalRailItemBox.height / 2,
);
await page.mouse.down();
await page.mouse.move(verticalRailItemBox.x + verticalRailItemBox.width / 2 + 8, verticalRailItemBox.y);
await expect(railItem).toHaveAttribute("data-dnd-drag-source", "true");
await page.mouse.move(railBox.x + railBox.width / 2, railBox.y + railBox.height - 2, { steps: 12 });
await page.waitForTimeout(250);
expect(await rail.evaluate((element) => element.scrollTop)).toBe(0);
expect((await expectBoundingBoxAsync(rail)).height).toBeCloseTo(fixedRailHeight, 1);
expect((await expectBoundingBoxAsync(railGrid)).height).toBeCloseTo(fixedRailGridHeight, 1);
await page.keyboard.press("Escape");
await page.mouse.up();
await expect.poll(async () => await readEditorGridSnapshotAsync(railGrid)).toEqual(railSnapshot);
await rail.locator('[data-e2e-rail-overflow-fixture="true"]').evaluate((element) => element.remove());
await page.evaluate((scrollY) => window.scrollTo({ top: scrollY, behavior: "auto" }), viewScrollY);
await expect(logicalTile).toHaveCSS("overflow-x", "hidden");
await expect(logicalTile).toHaveCSS("overflow-y", "auto");
await logicalTile.evaluate((element) => {
@@ -767,6 +802,42 @@ describe("Board grid", () => {
await expect(eastResizeHandle).not.toHaveAttribute("data-grid-resize-disabled");
await resizeLocatorByImmediateReleaseAsync(page, eastResizeHandle, logicalCellPitch * canvasScale, 0);
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
await expectGridEntryGeometrySettledAsync(containerSection);
await expect(eastResizeHandle).not.toHaveAttribute("data-grid-resize-disabled");
const expandingEastResizeBox = await expectBoundingBoxAsync(eastResizeHandle);
const expandingEastResizeStart = {
x: expandingEastResizeBox.x + expandingEastResizeBox.width / 2,
y: expandingEastResizeBox.y + expandingEastResizeBox.height / 2,
};
const expandingContainerBox = await expectBoundingBoxAsync(containerSection);
const eastHandleGrabOffset = expandingContainerBox.x + expandingContainerBox.width - expandingEastResizeStart.x;
const continuousExpansion = logicalCellPitch * canvasScale * 1.2;
await page.mouse.move(expandingEastResizeStart.x, expandingEastResizeStart.y);
await page.mouse.down();
await expect(page.locator("body")).toHaveAttribute("data-board-grid-interacting", "resize");
await page.mouse.move(expandingEastResizeStart.x + continuousExpansion, expandingEastResizeStart.y, {
steps: 12,
});
await expect(resizeOutline).toBeVisible();
const expandedOutlineBox = await expectBoundingBoxAsync(resizeOutline);
expect(expandedOutlineBox.x + expandedOutlineBox.width).toBeCloseTo(
expandingEastResizeStart.x + continuousExpansion + eastHandleGrabOffset,
1,
);
expect(
await resizeOutline.evaluate((element) => {
const probe = document.createElement("span");
probe.style.color = "var(--mantine-primary-color-filled)";
element.appendChild(probe);
const usesPrimaryColor = getComputedStyle(element).borderTopColor === getComputedStyle(probe).color;
probe.remove();
return usesPrimaryColor;
}),
).toBe(true);
await page.keyboard.press("Escape");
await page.mouse.up();
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
const northResizeHandle = containerSection.locator(':scope > [data-grid-resize-handle="n"]');
await expectTargetSizeAsync(northResizeHandle);