From dfe2ddf34b33ce72318cada7010a77fb8baa5997 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 11:02:55 +0200 Subject: [PATCH] fix: refine container interactions --- .../after-the-installation.mdx | 2 + .../board/sections/container-section.tsx | 61 +++---- .../sections/grid/dnd/coordinates.spec.ts | 58 ++++++- .../board/sections/grid/dnd/coordinates.ts | 73 ++++++++- .../board/sections/grid/dnd/index.ts | 8 +- .../sections/grid/grid-editor-runtime.tsx | 21 --- .../board/sections/grid/grid-editor.css | 25 ++- .../board/sections/grid/grid-editor.tsx | 155 ++++++++++++------ .../board/sections/grid/grid-portal-host.tsx | 24 +-- .../components/board/sections/item.module.css | 20 +++ e2e/board-grid.spec.ts | 37 ++++- 11 files changed, 340 insertions(+), 144 deletions(-) diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index 94acc1fea..97812b423 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -335,10 +335,12 @@ Each Container can configure: Open the add-content menu and choose **New Container**. In edit mode, move and resize it like any other item, then drag items into or out of it. Nested content stays within the Container's bounds. +While resizing, the solid outline follows the pointer continuously and the dashed grid preview shows the size that will be saved on release. ### Collapsing and opening apps Users can collapse an eligible Container like an accordion in view mode without changing its saved expanded size. +The full-width header becomes the compact expansion bar, so the half-row state has no empty pill-shaped spacer. Expanding it restores its bounds and makes room for its contents. If **Open all in new tabs** is enabled, the Container menu can open every app link it contains. diff --git a/apps/nextjs/src/components/board/sections/container-section.tsx b/apps/nextjs/src/components/board/sections/container-section.tsx index 5fd2a9831..c46a67887 100644 --- a/apps/nextjs/src/components/board/sections/container-section.tsx +++ b/apps/nextjs/src/components/board/sections/container-section.tsx @@ -38,7 +38,7 @@ export const BoardContainerSection = ({ section }: Props) => { }); const label = options.title.trim() || t("untitled"); const contentId = `board-container-${section.id}-content`; - const labelLeft = options.collapsible && !isEditMode ? 40 : 8; + const labelLeft = isEditMode ? 40 : 8; const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8; return ( @@ -62,29 +62,29 @@ export const BoardContainerSection = ({ section }: Props) => { radius={board.itemRadius} p={0} > - {isVisuallyCollapsed && ( + {options.collapsible && !isEditMode && ( )} {isEditMode && ( @@ -103,7 +103,7 @@ export const BoardContainerSection = ({ section }: Props) => { )} - {!isVisuallyCollapsed && options.showLabel && options.title && ( + {!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && ( { {options.title} )} - {options.collapsible && !isEditMode && !isVisuallyCollapsed && ( - - - - )} {options.showOpenAll && !isEditMode && ( { test.each([0.75, 1, 1.25, 2])("maps transformed drag geometry at %sx scale", (scale) => { @@ -64,6 +70,56 @@ describe("dnd grid coordinates", () => { ).toEqual({ x: -2, y: 4 }); }); + test("keeps the visual resize continuous between snapped grid breakpoints", () => { + const visualScale = 0.75; + const delta = getContinuousGridDelta({ + initial: { x: 10, y: 20 }, + current: { + x: 10 + LOGICAL_GRID_PITCH * visualScale * 0.42, + y: 20 + LOGICAL_GRID_PITCH * visualScale * 0.68, + }, + visualScale, + }); + + expect(delta?.x).toBeCloseTo(0.42); + expect(delta?.y).toBeCloseTo(0.68); + expect( + getSnappedGridDelta({ + initial: { x: 10, y: 20 }, + current: { + x: 10 + LOGICAL_GRID_PITCH * visualScale * 0.42, + y: 20 + LOGICAL_GRID_PITCH * visualScale * 0.68, + }, + visualScale, + }), + ).toEqual({ x: 0, y: 1 }); + }); + + test("projects a continuous resize while anchoring the opposite edges and respecting bounds", () => { + const placement = { id: "widget", x: 2, y: 3, w: 3, h: 2 }; + + expect( + getContinuousResizePlacement({ + placement, + direction: "nw", + deltaColumns: 0.35, + deltaRows: -0.6, + columnCount: 8, + maxRowCount: 10, + }), + ).toEqual({ id: "widget", x: 2.35, y: 2.4, w: 2.65, h: 2.6 }); + expect( + getContinuousResizePlacement({ + placement, + direction: "se", + deltaColumns: 99, + deltaRows: 99, + columnCount: 8, + maxRowCount: 7, + }), + ).toEqual({ id: "widget", x: 2, y: 3, w: 6, h: 4 }); + }); + test("uses independently measured visual axes for transformed resize geometry", () => { expect( getSnappedGridDelta({ diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts b/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts index 3944b8373..79349d94c 100644 --- a/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts +++ b/apps/nextjs/src/components/board/sections/grid/dnd/coordinates.ts @@ -1,4 +1,6 @@ import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout"; +import type { GridPlacement } from "~/components/board/layout"; +import type { GridResizeDirection } from "./types"; interface RectangleLike { left: number; @@ -54,11 +56,78 @@ export const getSnappedGridDelta = ({ current: { x: number; y: number }; initial: { x: number; y: number }; visualScale: number | { x: number; y: number }; +}) => { + const delta = getContinuousGridDelta({ current, initial, visualScale }); + if (!delta) return null; + return { + x: Math.round(delta.x), + y: Math.round(delta.y), + }; +}; + +export const getContinuousGridDelta = ({ + current, + initial, + visualScale, +}: { + current: { x: number; y: number }; + initial: { x: number; y: number }; + visualScale: number | { x: number; y: number }; }) => { const scale = typeof visualScale === "number" ? { x: visualScale, y: visualScale } : visualScale; if (!Number.isFinite(scale.x) || scale.x <= 0 || !Number.isFinite(scale.y) || scale.y <= 0) return null; return { - x: Math.round((current.x - initial.x) / (LOGICAL_GRID_PITCH * scale.x)), - y: Math.round((current.y - initial.y) / (LOGICAL_GRID_PITCH * scale.y)), + x: (current.x - initial.x) / (LOGICAL_GRID_PITCH * scale.x), + y: (current.y - initial.y) / (LOGICAL_GRID_PITCH * scale.y), }; }; + +/** Projects a resize continuously while the committed layout remains snapped. */ +export const getContinuousResizePlacement = ({ + placement, + direction, + deltaColumns, + deltaRows, + columnCount, + maxRowCount, + minWidth = 1, + minHeight = 1, +}: { + placement: TPlacement; + direction: GridResizeDirection; + deltaColumns: number; + deltaRows: number; + columnCount: number; + maxRowCount: number | null; + minWidth?: number; + minHeight?: number; +}): TPlacement => { + const fixedRight = placement.x + placement.w; + const fixedBottom = placement.y + placement.h; + let x = placement.x; + let y = placement.y; + let w = placement.w; + let h = placement.h; + + if (direction.includes("w")) { + w = clamp(placement.w - deltaColumns, minWidth, fixedRight); + x = fixedRight - w; + } else if (direction.includes("e")) { + w = clamp(placement.w + deltaColumns, minWidth, columnCount - placement.x); + } + + if (direction.includes("n")) { + h = clamp(placement.h - deltaRows, minHeight, fixedBottom); + y = fixedBottom - h; + } else if (direction.includes("s")) { + h = clamp( + placement.h + deltaRows, + minHeight, + maxRowCount === null ? Number.MAX_SAFE_INTEGER : maxRowCount - placement.y, + ); + } + + return { ...placement, x, y, w, h }; +}; + +const clamp = (value: number, minimum: number, maximum: number) => Math.min(Math.max(value, minimum), maximum); diff --git a/apps/nextjs/src/components/board/sections/grid/dnd/index.ts b/apps/nextjs/src/components/board/sections/grid/dnd/index.ts index e59fdd150..871820bc5 100644 --- a/apps/nextjs/src/components/board/sections/grid/dnd/index.ts +++ b/apps/nextjs/src/components/board/sections/grid/dnd/index.ts @@ -6,7 +6,13 @@ export { previewGridResize, resolvePinnedGridCollisions, } from "./engine"; -export { getPointerProjectedShape, getSnappedGridCoordinates, getSnappedGridDelta } from "./coordinates"; +export { + getContinuousGridDelta, + getContinuousResizePlacement, + getPointerProjectedShape, + getSnappedGridCoordinates, + getSnappedGridDelta, +} from "./coordinates"; export type { DragProjectionOrigin } from "./coordinates"; export { getPreferredNestedExitTargetId } from "./targeting"; export type { GridTargetGeometry } from "./targeting"; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor-runtime.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor-runtime.tsx index 7b89c9fcc..7b630970e 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor-runtime.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor-runtime.tsx @@ -5,16 +5,7 @@ import { createContext, useContext } from "react"; export type GridEditorRuntimeStatus = "idle" | "loading" | "ready" | "error"; -export interface GridResizePreview { - id: string; - x: number; - y: number; - w: number; - h: number; -} - const GridEditorRuntimeContext = createContext("idle"); -const GridResizePreviewContext = createContext(null); export const GridEditorRuntimeProvider = ({ children, @@ -24,15 +15,3 @@ export const GridEditorRuntimeProvider = ({ ); export const useGridEditorRuntimeStatus = () => useContext(GridEditorRuntimeContext); - -export const GridResizePreviewProvider = ({ - children, - value, -}: PropsWithChildren<{ value: GridResizePreview | null }>) => ( - {children} -); - -export const useGridResizePreview = (entryId: string) => { - const preview = useContext(GridResizePreviewContext); - return preview?.id === entryId ? preview : null; -}; diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.css b/apps/nextjs/src/components/board/sections/grid/grid-editor.css index 825b4505b..9d02c48c2 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.css +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.css @@ -73,8 +73,8 @@ zoom: var(--board-grid-overlay-content-zoom, 1); } -body[data-board-grid-interacting] .board-grid-entry, -body[data-board-grid-interacting] .board-grid-placeholder { +body[data-board-grid-interacting="drag"] .board-grid-entry, +body[data-board-grid-interacting="drag"] .board-grid-placeholder { transition: none; } @@ -99,6 +99,24 @@ body[data-board-grid-interacting] .board-grid-placeholder { height 100ms ease; } +.board-grid-placeholder[data-grid-placeholder-mode="resize"] { + z-index: 1001; + background: color-mix(in srgb, var(--mantine-primary-color-filled) 10%, transparent); +} + +.board-grid-resize-outline { + z-index: 1002; + background: color-mix(in srgb, var(--mantine-primary-color-filled) 6%, transparent); + border: 2px solid var(--mantine-primary-color-filled); + border-radius: var(--mantine-radius-md); + 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; @@ -110,7 +128,7 @@ body[data-board-grid-interacting] .board-grid-placeholder { .board-grid-resize-handle { position: absolute; - z-index: 8; + z-index: 20; display: block; padding: 0; appearance: none; @@ -246,6 +264,7 @@ body[data-board-grid-interacting] .board-grid-placeholder { @media (prefers-reduced-motion: reduce) { .board-grid-entry, .board-grid-placeholder, + .board-grid-resize-outline, .board-grid-resize-handle::after { transition: none; } diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx index bc16af38a..192cbf7f9 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx @@ -36,6 +36,8 @@ import { beginGridTransaction, cancelGridTransaction, commitGridTransaction, + getContinuousGridDelta, + getContinuousResizePlacement, getPreferredNestedExitTargetId, getPointerProjectedShape, getSnappedGridCoordinates, @@ -45,7 +47,6 @@ import { } from "./dnd"; import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, TransactionalGridState } from "./dnd"; import { getGridDepth } from "./grid-depth"; -import { GridResizePreviewProvider } from "./grid-editor-runtime"; import { useBoardGridPortalHost } from "./grid-portal-host"; import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions"; import { useGridLayoutActions } from "./use-grid-layout-actions"; @@ -602,7 +603,10 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { activeId: active.transaction.activeId, sourceGridId: active.transaction.sourceGridId, targetGridId: active.transaction.sourceGridId, - targetPlacement: null, + targetPlacement: + result.transaction.preview.grids + .find((grid) => grid.id === active.transaction.sourceGridId) + ?.placements.find((placement) => placement.id === active.transaction.activeId) ?? null, state: result.transaction.preview, mode: "resize", valid: true, @@ -644,15 +648,6 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { registerGrid, ], ); - const resizePreview = useMemo(() => { - if (interaction?.mode !== "resize") return null; - return ( - interaction.state.grids - .flatMap((grid) => grid.placements) - .find((placement) => placement.id === interaction.activeId) ?? null - ); - }, [interaction]); - useEffect(() => { if (interaction?.mode !== "resize") return; const handleKeyDown = (event: KeyboardEvent) => { @@ -666,35 +661,33 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => { return ( - - + {children} + - {children} - - {(source) => { - const data = getGridEntryData(source); - return data ?
{data.label}
: null; - }} -
- -
-
+ {(source) => { + const data = getGridEntryData(source); + return data ?
{data.label}
: null; + }} + + +
); }; @@ -726,23 +719,21 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement const renderPlacements = useMemo(() => { if (!previewGrid || !interaction) return placements; - const preview = previewGrid.placements.filter( - (placement) => interaction.mode === "resize" || placement.id !== interaction.activeId, - ); - if (interaction.mode === "drag" && interaction.sourceGridId === sectionId) { + if (interaction.sourceGridId === sectionId) { const original = controlledById.get(interaction.activeId); - return original ? [...preview, original] : preview; + if (!original) return previewGrid.placements; + return previewGrid.placements.some((placement) => placement.id === interaction.activeId) + ? previewGrid.placements.map((placement) => (placement.id === interaction.activeId ? original : placement)) + : [...previewGrid.placements, original]; } - return preview; + return previewGrid.placements.filter((placement) => placement.id !== interaction.activeId); }, [controlledById, interaction, placements, previewGrid, sectionId]); const entryById = useMemo( () => new Map([...items, ...innerSections].map((entry) => [entry.id, entry])), [innerSections, items], ); const targetPlacement = - interaction?.mode === "drag" && interaction.valid && interaction.targetGridId === sectionId - ? interaction.targetPlacement - : null; + interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null; const renderedRowCount = section.kind !== "container" && interaction?.mode === "resize" ? Math.max(rowCount, getLayoutRowCount(renderPlacements)) @@ -817,13 +808,23 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement ? entry.advancedOptions.title?.trim() || t(`widget.${entry.kind}.name`) : entry.options.title.trim() || t("section.container.untitled") : placement.id; - return ; + return ( + + ); })} {targetPlacement && (
{ +const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }: DndGridEntryProps) => { const { acquireContainer } = useBoardGridPortalHost(); const { interaction, beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditor(); const [isSectionChromeActive, setIsSectionChromeActive] = useState(false); + const [continuousResize, setContinuousResize] = useState<{ + placement: SectionGridPlacement; + valid: boolean; + } | null>(null); const mountRef = useRef(null); const shellRef = useRef(null); const sourceData = useMemo( @@ -932,6 +939,14 @@ const DndGridEntry = ({ sectionId, placement, label }: DndGridEntryProps) => { const isActive = interaction?.activeId === placement.id; const style = getLogicalItemStyle(placement) as CSSProperties; + const continuousResizeStyle = + continuousResize && isActive + ? getLogicalItemStyle({ + ...continuousResize.placement, + x: continuousResize.placement.x - placement.x, + y: continuousResize.placement.y - placement.y, + }) + : undefined; return (
{ data-dnd-dropping={isDropping ? "true" : undefined} >
+ {continuousResizeStyle && ( + ); @@ -969,10 +996,13 @@ interface GridResizeHandlesProps { placement: SectionGridPlacement; label: string; disabled: boolean; + columnCount: number; + maxRowCount: number | null; beginResize: BoardGridEditorContextValue["beginResize"]; previewResize: BoardGridEditorContextValue["previewResize"]; completeResize: BoardGridEditorContextValue["completeResize"]; cancelResize: BoardGridEditorContextValue["cancelResize"]; + setContinuousResize: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void; } const RESIZE_DIRECTIONS: readonly GridResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"]; @@ -982,10 +1012,13 @@ const GridResizeHandles = ({ placement, label, disabled, + columnCount, + maxRowCount, beginResize, previewResize, completeResize, cancelResize, + setContinuousResize, }: GridResizeHandlesProps) => { const pointerStartRef = useRef<{ id: number; @@ -1033,18 +1066,35 @@ const GridResizeHandles = ({ const started = pointerStartRef.current; if (!started || started.id !== event.pointerId) return; event.preventDefault(); - const delta = getSnappedGridDelta({ + const input = { initial: { x: started.x, y: started.y }, current: { x: event.clientX, y: event.clientY }, visualScale: started.visualScale, + }; + const snappedDelta = getSnappedGridDelta(input); + const continuousDelta = getContinuousGridDelta(input); + if (!snappedDelta || !continuousDelta) return; + const valid = update(started.direction, snappedDelta.x, snappedDelta.y); + setContinuousResize({ + placement: getContinuousResizePlacement({ + placement, + direction: started.direction, + deltaColumns: continuousDelta.x, + deltaRows: continuousDelta.y, + columnCount, + maxRowCount, + minWidth: placement.minW ?? 1, + minHeight: placement.minH ?? 1, + }), + valid, }); - if (delta) update(started.direction, delta.x, delta.y); }; const handlePointerEnd = (event: ReactPointerEvent, canceled: boolean) => { const started = pointerStartRef.current; if (!started || started.id !== event.pointerId) return; pointerStartRef.current = null; + setContinuousResize(null); if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); if (canceled) cancelResize(); @@ -1073,6 +1123,7 @@ const GridResizeHandles = ({ onLostPointerCapture={(event) => { if (pointerStartRef.current?.id !== event.pointerId) return; pointerStartRef.current = null; + setContinuousResize(null); cancelResize(); }} /> diff --git a/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx b/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx index c2146ea50..8f23db2d1 100644 --- a/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx +++ b/apps/nextjs/src/components/board/sections/grid/grid-portal-host.tsx @@ -14,7 +14,6 @@ import { getBoardLaneColumnCount } from "~/components/board/layout"; import { SectionContentItem } from "../content"; import { SectionProvider } from "../section-context"; import { useSectionItems } from "../use-section-items"; -import { useGridResizePreview } from "./grid-editor-runtime"; import classes from "./section-grid.module.css"; interface GridPortalHostContextValue { @@ -138,15 +137,12 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G const board = useRequiredBoard(); const currentLayoutId = useCurrentLayout(); const { items, innerSections } = useSectionItems(ownerSectionId); - const entryPreview = useGridResizePreview(itemId); - const ownerPreview = useGridResizePreview(ownerSectionId); const persistedEntry = [...items, ...innerSections].find((candidate) => candidate.id === itemId); - const entry = withResizePreview(persistedEntry, entryPreview); + const entry = persistedEntry; const rawSection = board.sections.find((section) => section.id === ownerSectionId); const currentLayout = board.layouts.find((layout) => layout.id === currentLayoutId); const persistedSection = toGridSection(rawSection, currentLayoutId); - const section = - persistedSection?.kind === "container" ? withResizePreview(persistedSection, ownerPreview) : persistedSection; + const section = persistedSection; if (!entry || !section || !currentLayout) return null; @@ -181,22 +177,6 @@ const GridPortalContent = ({ itemId, ownerSectionId, integrations, announce }: G ); }; -const withResizePreview = < - TValue extends { xOffset: number; yOffset: number; width: number; height: number } | null | undefined, ->( - value: TValue, - preview: { x: number; y: number; w: number; h: number } | null, -): TValue => - value && preview - ? ({ - ...value, - xOffset: preview.x, - yOffset: preview.y, - width: preview.w, - height: preview.h, - } as TValue) - : value; - const toGridSection = ( section: Section | undefined, layoutId: string, diff --git a/apps/nextjs/src/components/board/sections/item.module.css b/apps/nextjs/src/components/board/sections/item.module.css index aa814566a..de643a756 100644 --- a/apps/nextjs/src/components/board/sections/item.module.css +++ b/apps/nextjs/src/components/board/sections/item.module.css @@ -21,3 +21,23 @@ border-color: transparent !important; box-shadow: none !important; } + +.containerToggle { + z-index: 9; + overflow: hidden; + color: var(--mantine-color-text); + background-color: var(--background-color); + border-color: var(--border-color); +} + +.containerToggle :global(.mantine-Button-inner) { + width: 100%; + min-width: 0; +} + +.containerToggle :global(.mantine-Button-label) { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts index 44a68793e..199edfe6b 100644 --- a/e2e/board-grid.spec.ts +++ b/e2e/board-grid.spec.ts @@ -104,6 +104,10 @@ describe("Board grid", () => { await expect(containerSection).toHaveAttribute("data-grid-h", "2"); const containerLabel = containerSection.locator("[data-board-container-label]"); await expect(containerLabel).toHaveText("Nested box"); + const expandedContainerBox = await expectBoundingBoxAsync(containerSection); + const expandedContainerLabelBox = await expectBoundingBoxAsync(containerLabel); + expect(Math.abs(expandedContainerLabelBox.x - expandedContainerBox.x)).toBeLessThanOrEqual(3); + expect(Math.abs(expandedContainerLabelBox.width - expandedContainerBox.width)).toBeLessThanOrEqual(3); await expect(nestedItem).toHaveAttribute("data-grid-x", "0"); await expect(belowItem).toHaveAttribute("data-grid-y", "2"); const containerLabelBox = await expectBoundingBoxAsync(containerLabel); @@ -193,6 +197,12 @@ describe("Board grid", () => { await expect .poll(async () => (await expectBoundingBoxAsync(containerSection)).height) .toBeCloseTo(expectedCollapsedHeight, 1); + const collapsedContainerBox = await expectBoundingBoxAsync(containerSection); + const collapsedControlBox = await expectBoundingBoxAsync(collapsedControl); + expect(Math.abs(collapsedControlBox.x - collapsedContainerBox.x)).toBeLessThanOrEqual(3); + expect(Math.abs(collapsedControlBox.y - collapsedContainerBox.y)).toBeLessThanOrEqual(3); + expect(Math.abs(collapsedControlBox.width - collapsedContainerBox.width)).toBeLessThanOrEqual(3); + expect(Math.abs(collapsedControlBox.height - collapsedContainerBox.height)).toBeLessThanOrEqual(3); await page .getByRole("button", { @@ -637,13 +647,34 @@ describe("Board grid", () => { }; await page.mouse.move(eastResizeStart.x, eastResizeStart.y); await page.mouse.down(); - await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale, eastResizeStart.y, { steps: 12 }); - await expect(containerSection).toHaveAttribute("data-grid-w", "1"); + await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale * 0.35, eastResizeStart.y, { + steps: 6, + }); + const resizeOutline = containerSection.locator(":scope > [data-grid-resize-outline]"); + const resizePlaceholder = mainGrid.locator(`[data-grid-placeholder-for="${fixture.containerSectionId}"]`); + await expect(resizeOutline).toBeVisible(); + await expect(resizeOutline).toHaveAttribute("data-grid-resize-valid", "true"); + await expect(resizePlaceholder).toHaveAttribute("data-grid-placeholder-mode", "resize"); + await expect(resizePlaceholder).toHaveAttribute("data-grid-w", "2"); + await expect(containerSection).toHaveAttribute("data-grid-w", "2"); + const originalResizeWidth = (await expectBoundingBoxAsync(containerSection)).width; + const continuousResizeWidth = (await expectBoundingBoxAsync(resizeOutline)).width; + expect(continuousResizeWidth).toBeLessThan(originalResizeWidth); + expect(continuousResizeWidth).toBeGreaterThan(logicalCellSize * canvasScale); + + await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale, eastResizeStart.y, { steps: 6 }); + await expect(containerSection).toHaveAttribute("data-grid-w", "2"); + await expect(resizePlaceholder).toHaveAttribute("data-grid-w", "1"); + expect((await expectBoundingBoxAsync(resizeOutline)).width).toBeCloseTo(logicalCellSize * canvasScale, 1); const liveNestedGridBox = await expectBoundingBoxAsync(nestedEditor); const liveContainerBox = await expectBoundingBoxAsync(containerSection); - expect(liveNestedGridBox.width).toBeCloseTo(logicalCellSize * canvasScale, 1); + expect(liveNestedGridBox.width).toBeCloseTo(originalResizeWidth, 1); expect(liveNestedGridBox.width).toBeLessThanOrEqual(liveContainerBox.width + 1); await page.mouse.up(); + await expect(containerSection).toHaveAttribute("data-grid-w", "1"); + await expect(resizeOutline).toHaveCount(0); + await expect(containerSection).toHaveAttribute("data-grid-section-chrome-active", "true"); + await expect(eastResizeHandle).not.toHaveAttribute("data-grid-resize-disabled"); await resizeLocatorByImmediateReleaseAsync(page, eastResizeHandle, logicalCellPitch * canvasScale, 0); await expect(containerSection).toHaveAttribute("data-grid-w", "2");