fix: refine container interactions

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:06 +02:00
1 parent 34f2a0bd24
commit dfe2ddf34b
11 files changed
+340 -144

No files matched your search

@@ -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.
@@ -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 && (
<Button
className={classes.containerToggle}
pos="absolute"
top={0}
top={isVisuallyCollapsed ? 0 : -28}
left={0}
maw={`calc(100% - ${options.showOpenAll ? 40 : 0}px)`}
size="compact-sm"
radius="xl"
w="100%"
h={isVisuallyCollapsed ? "100%" : 28}
px={12}
pe={options.showOpenAll ? 40 : 12}
radius={isVisuallyCollapsed ? board.itemRadius : "sm"}
variant="default"
leftSection={<IconChevronDown size={14} />}
justify={options.showLabel ? "flex-start" : "center"}
leftSection={options.showLabel ? isVisuallyCollapsed ? <IconChevronDown size={16} /> : <IconChevronUp size={16} /> : undefined}
onClick={toggle}
aria-expanded={false}
aria-expanded={!isVisuallyCollapsed}
aria-controls={contentId}
aria-label={`${t("action.expand")}: ${label}`}
data-board-container-collapsed-control
styles={{
label: {
overflow: "hidden",
textOverflow: "ellipsis",
},
}}
aria-label={`${t(isVisuallyCollapsed ? "action.expand" : "action.collapse")}: ${label}`}
data-board-container-collapsed-control={isVisuallyCollapsed ? "true" : undefined}
data-board-container-label
title={options.showLabel ? label : undefined}
>
{label}
{options.showLabel ? label : isVisuallyCollapsed ? <IconChevronDown size={16} /> : <IconChevronUp size={16} />}
</Button>
)}
{isEditMode && (
@@ -103,7 +103,7 @@ export const BoardContainerSection = ({ section }: Props) => {
<IconGripVertical size={16} />
</ActionIcon>
)}
{!isVisuallyCollapsed && options.showLabel && options.title && (
{!isVisuallyCollapsed && (!options.collapsible || isEditMode) && options.showLabel && options.title && (
<Badge
pos="absolute"
top={-24}
@@ -128,30 +128,13 @@ export const BoardContainerSection = ({ section }: Props) => {
{options.title}
</Badge>
)}
{options.collapsible && !isEditMode && !isVisuallyCollapsed && (
<ActionIcon
pos="absolute"
top={-24}
left={4}
style={{ zIndex: 10 }}
variant="default"
size={24}
radius="sm"
onClick={toggle}
aria-expanded={!isVisuallyCollapsed}
aria-controls={contentId}
aria-label={`${isVisuallyCollapsed ? t("action.expand") : t("action.collapse")}: ${label}`}
>
<IconChevronUp size={16} />
</ActionIcon>
)}
{options.showOpenAll && !isEditMode && (
<ActionIcon
pos="absolute"
top={isVisuallyCollapsed ? 0 : -24}
right={4}
style={{ zIndex: 10 }}
variant="default"
top={isVisuallyCollapsed ? "50%" : -26}
right={2}
style={{ zIndex: 10, transform: isVisuallyCollapsed ? "translateY(-50%)" : undefined }}
variant={options.collapsible ? "subtle" : "default"}
size={24}
radius="sm"
loading={areAppsLoading}
@@ -1,7 +1,13 @@
import { describe, expect, test } from "vitest";
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import { getPointerProjectedShape, getSnappedGridCoordinates, getSnappedGridDelta } from "./coordinates";
import {
getContinuousGridDelta,
getContinuousResizePlacement,
getPointerProjectedShape,
getSnappedGridCoordinates,
getSnappedGridDelta,
} from "./coordinates";
describe("dnd grid coordinates", () => {
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({
@@ -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 = <TPlacement extends GridPlacement>({
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);
@@ -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";
@@ -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<GridEditorRuntimeStatus>("idle");
const GridResizePreviewContext = createContext<GridResizePreview | null>(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 }>) => (
<GridResizePreviewContext.Provider value={value}>{children}</GridResizePreviewContext.Provider>
);
export const useGridResizePreview = (entryId: string) => {
const preview = useContext(GridResizePreviewContext);
return preview?.id === entryId ? preview : null;
};
@@ -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;
}
@@ -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 (
<BoardGridEditorContext.Provider value={value}>
<GridResizePreviewProvider value={resizePreview}>
<DragDropProvider
plugins={configureEditorPlugins}
onCollision={handleCollision}
onBeforeDragStart={handleBeforeDragStart}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
<DragDropProvider
plugins={configureEditorPlugins}
onCollision={handleCollision}
onBeforeDragStart={handleBeforeDragStart}
onDragStart={handleDragStart}
onDragMove={handleDragMove}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
{children}
<DragOverlay
className="board-grid-drag-overlay"
dropAnimation={null}
style={
{
zoom: overlayZoom,
"--board-grid-overlay-content-zoom": 1 / overlayZoom,
} as CSSProperties
}
>
{children}
<DragOverlay
className="board-grid-drag-overlay"
dropAnimation={null}
style={
{
zoom: overlayZoom,
"--board-grid-overlay-content-zoom": 1 / overlayZoom,
} as CSSProperties
}
>
{(source) => {
const data = getGridEntryData(source);
return data ? <div className="board-grid-drag-overlay-card">{data.label}</div> : null;
}}
</DragOverlay>
<GridCollisionSynchronizer interaction={interaction} />
</DragDropProvider>
</GridResizePreviewProvider>
{(source) => {
const data = getGridEntryData(source);
return data ? <div className="board-grid-drag-overlay-card">{data.label}</div> : null;
}}
</DragOverlay>
<GridCollisionSynchronizer interaction={interaction} />
</DragDropProvider>
</BoardGridEditorContext.Provider>
);
};
@@ -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 <DndGridEntry key={placement.id} sectionId={sectionId} placement={placement} label={label} />;
return (
<DndGridEntry
key={placement.id}
sectionId={sectionId}
placement={placement}
label={label}
columnCount={columnCount}
maxRowCount={section.kind === "container" ? rowCount : null}
/>
);
})}
{targetPlacement && (
<div
className="board-grid-placeholder"
style={getLogicalItemStyle(targetPlacement)}
data-grid-placeholder-for={interaction?.activeId}
data-grid-placeholder-mode={interaction?.mode}
data-grid-x={targetPlacement.x}
data-grid-y={targetPlacement.y}
data-grid-w={targetPlacement.w}
@@ -845,12 +846,18 @@ interface DndGridEntryProps {
sectionId: string;
placement: SectionGridPlacement;
label: string;
columnCount: number;
maxRowCount: number | null;
}
const DndGridEntry = ({ sectionId, placement, label }: DndGridEntryProps) => {
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<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const sourceData = useMemo<GridEntryData>(
@@ -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 (
<div
@@ -950,15 +965,27 @@ const DndGridEntry = ({ sectionId, placement, label }: DndGridEntryProps) => {
data-dnd-dropping={isDropping ? "true" : undefined}
>
<div ref={mountRef} className="board-grid-content-mount" />
{continuousResizeStyle && (
<div
className="board-grid-resize-outline"
style={continuousResizeStyle}
data-grid-resize-outline
data-grid-resize-valid={String(continuousResize?.valid)}
aria-hidden="true"
/>
)}
<GridResizeHandles
sectionId={sectionId}
placement={placement}
label={label}
disabled={interaction !== null || (placement.type === "section" && !isSectionChromeActive)}
columnCount={columnCount}
maxRowCount={maxRowCount}
beginResize={beginResize}
previewResize={previewResize}
completeResize={completeResize}
cancelResize={cancelResize}
setContinuousResize={setContinuousResize}
/>
</div>
);
@@ -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<HTMLSpanElement>, 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();
}}
/>
@@ -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,
@@ -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;
}
+34 -3
View File
@@ -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");