fix: polish board grid interactions

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:06 +02:00
1 parent 4227e392b5
commit 34f2a0bd24
25 files changed
+517 -178

No files matched your search

@@ -12,6 +12,8 @@
position: sticky;
top: 0;
align-self: start;
padding-top: 24px;
margin-top: -24px;
max-height: calc(100dvh - var(--app-shell-header-offset, 0px) - var(--mantine-spacing-md));
overflow-y: auto;
overscroll-behavior: contain;
@@ -13,11 +13,14 @@
.editModeInertContent {
display: contents;
}
.editModeInertContent[data-board-grid-inert-content="true"] {
pointer-events: none;
user-select: none;
}
.editModeInertContent * {
.editModeInertContent[data-board-grid-inert-content="true"] * {
pointer-events: none !important;
user-select: none !important;
}
@@ -41,7 +41,7 @@ const LoadedWidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayProp
);
return (
<div className={classes.wrapper}>
<div className={classes.wrapper} data-board-widget-header>
<div className={classes.panel}>
<div className={classes.nameSection}>
<WidgetIcon size={14} stroke={1.75} className={classes.widgetIcon} />
@@ -7,4 +7,4 @@ export const LOGICAL_GRID_GAP = 24;
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
export const MIN_ACCESSIBLE_CANVAS_SCALE = 0.75;
export const COLLAPSED_SECTION_ROW_COUNT = 1;
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
@@ -4,7 +4,7 @@ import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "./
import type { GridCoordinates, GridPlacement } from "./types";
export const getLogicalTrackSize = (trackCount: number) => {
const normalizedTrackCount = toNonNegativeInteger(trackCount);
const normalizedTrackCount = Math.max(0, toFiniteNumber(trackCount, "trackCount"));
if (normalizedTrackCount === 0) return 0;
return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP;
@@ -63,21 +63,21 @@ export const getCollapsedDisplayLayout = <TPlacement extends GridPlacement>(
placements: readonly TPlacement[],
{ columnCount, collapsedItemIds, collapsedRowCount = COLLAPSED_SECTION_ROW_COUNT }: CollapsedLayoutOptions,
): TPlacement[] => {
if (collapsedRowCount < 1 || !Number.isInteger(collapsedRowCount)) {
throw new RangeError("collapsedRowCount must be a positive integer");
if (!Number.isFinite(collapsedRowCount) || collapsedRowCount <= 0) {
throw new RangeError("collapsedRowCount must be positive and finite");
}
return reflowVerticalLayout(
placements.map((placement) =>
collapsedItemIds.has(placement.id)
? {
...placement,
h: collapsedRowCount,
}
: placement,
),
{ columnCount },
const normalized = placements.map((placement) => normalizeGridPlacement(placement, columnCount));
const displayPlacements = normalized.map((placement) =>
collapsedItemIds.has(placement.id)
? {
...placement,
h: collapsedRowCount,
}
: placement,
);
return reflowDisplayLayout(displayPlacements);
};
/**
@@ -118,6 +118,21 @@ const findFirstAvailableRow = (candidate: GridPlacement, placed: readonly GridPl
}
};
const reflowDisplayLayout = <TPlacement extends GridPlacement>(placements: readonly TPlacement[]): TPlacement[] => {
assertUniqueIds(placements);
const placed: TPlacement[] = [];
for (const candidate of placements.toSorted(comparePlacements)) {
placed.push({
...candidate,
y: findFirstAvailableRow(candidate, placed),
});
}
const byId = new Map(placed.map((placement) => [placement.id, placement]));
return placements.map((placement) => getRequiredPlacement(byId, placement.id));
};
const comparePlacements = (first: GridPlacement, second: GridPlacement) =>
first.y - second.y || first.x - second.x || (first.id === second.id ? 0 : first.id < second.id ? -1 : 1);
@@ -10,6 +10,7 @@ import {
describe("fixed dashboard geometry", () => {
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
expect(getLogicalTrackSize(1)).toBe(200);
expect(getLogicalTrackSize(0.5)).toBe(88);
expect(getLogicalTrackSize(2)).toBe(424);
expect(getLogicalTrackSize(3)).toBe(648);
@@ -42,8 +42,8 @@ describe("dashboard layout reflow", () => {
);
expect(result).toEqual([
{ id: "section", x: 0, y: 0, w: 2, h: 1 },
{ id: "below", x: 0, y: 1, w: 2, h: 1 },
{ id: "section", x: 0, y: 0, w: 2, h: 0.5 },
{ id: "below", x: 0, y: 0.5, w: 2, h: 1 },
{ id: "other-lane", x: 2, y: 0, w: 1, h: 1 },
]);
});
@@ -1,5 +1,5 @@
import dynamic from "next/dynamic";
import { ActionIcon, Badge, Box, Card } from "@mantine/core";
import { ActionIcon, Badge, Box, Button, Card } from "@mantine/core";
import { IconChevronDown, IconChevronUp, IconExternalLink, IconGripVertical } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -36,17 +36,22 @@ export const BoardContainerSection = ({ section }: Props) => {
sectionId: section.id,
collapsible: options.collapsible,
});
const label = options.title || t("action.create");
const label = options.title.trim() || t("untitled");
const contentId = `board-container-${section.id}-content`;
const labelLeft = isEditMode || options.collapsible ? 40 : 10;
const labelLeft = options.collapsible && !isEditMode ? 40 : 8;
const labelRight = isEditMode ? 48 : options.showOpenAll ? 40 : 8;
return (
<Box className="board-grid-item-content" data-grid-item-content w="100%" h="100%" style={{ overflow: "visible" }}>
<Card
className={combineClasses(classes.itemCard, options.customCssClasses.join(" "))}
className={combineClasses(
classes.itemCard,
options.customCssClasses.join(" "),
isVisuallyCollapsed && classes.collapsedContainerCard,
)}
w="100%"
h="100%"
data-board-container-collapsed={isVisuallyCollapsed ? "true" : "false"}
styles={{
root: {
overflow: "visible",
@@ -57,6 +62,31 @@ export const BoardContainerSection = ({ section }: Props) => {
radius={board.itemRadius}
p={0}
>
{isVisuallyCollapsed && (
<Button
pos="absolute"
top={0}
left={0}
maw={`calc(100% - ${options.showOpenAll ? 40 : 0}px)`}
size="compact-sm"
radius="xl"
variant="default"
leftSection={<IconChevronDown size={14} />}
onClick={toggle}
aria-expanded={false}
aria-controls={contentId}
aria-label={`${t("action.expand")}: ${label}`}
data-board-container-collapsed-control
styles={{
label: {
overflow: "hidden",
textOverflow: "ellipsis",
},
}}
>
{label}
</Button>
)}
{isEditMode && (
<ActionIcon
component="span"
@@ -65,53 +95,65 @@ export const BoardContainerSection = ({ section }: Props) => {
left={4}
style={{ zIndex: 10, cursor: "grab", touchAction: "none" }}
variant="default"
radius="xl"
size={24}
radius="sm"
data-grid-container-drag-handle
aria-hidden="true"
>
<IconGripVertical size={16} />
</ActionIcon>
)}
{options.showLabel && options.title && (
{!isVisuallyCollapsed && options.showLabel && options.title && (
<Badge
pos="absolute"
top={6}
top={-24}
left={labelLeft}
maw={`calc(100% - ${labelLeft + labelRight}px)`}
size="md"
radius={board.itemRadius}
color="var(--background-color)"
radius="sm"
variant="default"
c="var(--mantine-color-text)"
bd="1px solid var(--border-color)"
style={{ zIndex: 9, pointerEvents: "none" }}
style={{
zIndex: 9,
overflow: "hidden",
pointerEvents: "none",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
backgroundColor: "var(--background-color)",
borderColor: "var(--border-color)",
}}
title={options.title}
data-board-container-label
>
{options.title}
</Badge>
)}
{options.collapsible && !isEditMode && (
{options.collapsible && !isEditMode && !isVisuallyCollapsed && (
<ActionIcon
pos="absolute"
top={4}
top={-24}
left={4}
style={{ zIndex: 10 }}
variant="default"
radius="xl"
size={24}
radius="sm"
onClick={toggle}
aria-expanded={!isVisuallyCollapsed}
aria-controls={contentId}
aria-label={`${isVisuallyCollapsed ? t("action.expand") : t("action.collapse")}: ${label}`}
>
{isVisuallyCollapsed ? <IconChevronDown size={16} /> : <IconChevronUp size={16} />}
<IconChevronUp size={16} />
</ActionIcon>
)}
{options.showOpenAll && !isEditMode && (
<ActionIcon
pos="absolute"
top={4}
top={isVisuallyCollapsed ? 0 : -24}
right={4}
style={{ zIndex: 10 }}
variant="default"
radius="xl"
size={24}
radius="sm"
loading={areAppsLoading}
onClick={openAllInNewTabs}
aria-label={tAll("section.action.openAllInNewTabsFor", { name: label })}
@@ -46,9 +46,10 @@ export const BoardContainerMenu = ({ section }: { section: ContainerSectionItem
<Menu.Target>
<ActionIcon
variant="default"
radius="xl"
size={24}
radius="sm"
pos="absolute"
top={4}
top={-24}
right={menuRightOffset}
style={{ zIndex: 10 }}
aria-label={tItem("menu.label.settingsFor", { name: label })}
@@ -17,7 +17,7 @@ describe("dnd grid coordinates", () => {
test("uses the dragged shape origin so a non-center grab retains its pointer offset", () => {
const scale = 0.75;
const target = { left: -280, top: 390, width: getLogicalTrackSize(5) * scale };
const initialShape = { left: target.left, top: target.top };
const initialShape = { left: target.left, top: target.top, width: 424 * scale, height: 200 * scale };
const pointerDelta = { x: LOGICAL_GRID_PITCH * scale * 2, y: LOGICAL_GRID_PITCH * scale * 3 };
expect(
@@ -37,6 +37,8 @@ describe("dnd grid coordinates", () => {
const initialShape = {
left: target.left + 4 * LOGICAL_GRID_PITCH * scale,
top: target.top + 8 * LOGICAL_GRID_PITCH * scale,
width: getLogicalTrackSize(2) * scale,
height: getLogicalTrackSize(2) * scale,
};
const initialPointer = { x: initialShape.left + 134 * scale, y: initialShape.top + 89 * scale };
const dragShape = getPointerProjectedShape(
@@ -45,6 +47,7 @@ describe("dnd grid coordinates", () => {
);
expect(dragShape.left).toBe(initialShape.left);
expect(dragShape).toMatchObject({ width: initialShape.width, height: initialShape.height });
expect(getSnappedGridCoordinates({ dragShape, target, columnCount: 8 })).toEqual({ x: 4, y: 7 });
});
@@ -6,9 +6,13 @@ interface RectangleLike {
width: number;
}
interface ProjectedRectangle extends RectangleLike {
height: number;
}
export interface DragProjectionOrigin {
initialPointer: { x: number; y: number };
initialShape: { left: number; top: number };
initialShape: ProjectedRectangle;
}
/**
@@ -19,6 +23,8 @@ export interface DragProjectionOrigin {
export const getPointerProjectedShape = (origin: DragProjectionOrigin, currentPointer: { x: number; y: number }) => ({
left: origin.initialShape.left + currentPointer.x - origin.initialPointer.x,
top: origin.initialShape.top + currentPointer.y - origin.initialPointer.y,
width: origin.initialShape.width,
height: origin.initialShape.height,
});
export const getSnappedGridCoordinates = ({
@@ -258,7 +258,7 @@ describe("same-grid movement", () => {
});
});
test("uses pinned reflow for a partial overlap even when sizes match", () => {
test("swaps laterally as soon as a move pushes into one compatible item", () => {
const original = state(grid("root", 5, [placement("active", 0, 0, 2), placement("target", 2, 0, 2)]));
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
@@ -268,8 +268,22 @@ describe("same-grid movement", () => {
}),
);
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 1, y: 0, w: 2, h: 1 });
expect(getPlacement(moved.preview, "target")).toMatchObject({ x: 2, y: 1, w: 2, h: 1 });
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 2, y: 0, w: 2, h: 1 });
expect(getPlacement(moved.preview, "target")).toMatchObject({ x: 0, y: 0, w: 2, h: 1 });
});
test("swaps the pushed item into the vacated slot when moving from right to left", () => {
const original = state(grid("root", 5, [placement("target", 0, 0, 2), placement("active", 2, 0, 2)]));
const moved = accept(
previewGridMove(beginGridTransaction(original, { activeId: "active" }), {
targetGridId: "root",
x: 1,
y: 0,
}),
);
expect(getPlacement(moved.preview, "active")).toMatchObject({ x: 0, y: 0, w: 2, h: 1 });
expect(getPlacement(moved.preview, "target")).toMatchObject({ x: 2, y: 0, w: 2, h: 1 });
});
test("preserves intentional empty rows outside the collision chain", () => {
@@ -41,10 +41,10 @@ export const beginGridTransaction = <TPlacement extends GridPlacement>(
};
/**
* Computes a move preview from the transaction snapshot. Same-grid exact-slot
* drops swap items when both resulting placements remain valid; every other
* move pins the active item and pushes only actual collisions. Intentional
* empty rows remain untouched.
* Computes a move preview from the transaction snapshot. Same-grid moves that
* collide with one item swap into that item's slot when both resulting
* placements remain valid; every other move pins the active item and pushes
* only actual collisions. Intentional empty rows remain untouched.
*/
export const previewGridMove = <TPlacement extends GridPlacement>(
transaction: GridTransaction<TPlacement>,
@@ -147,13 +147,14 @@ const previewSameGridMove = <TPlacement extends GridPlacement>(
const previewGrid = getGrid(preview, grid.id);
if (areCoordinatesEqual(active, nextActive)) return preview;
const displaced = grid.placements.find(
(placement) => placement.id !== active.id && placement.x === nextActive.x && placement.y === nextActive.y,
const collisions = grid.placements.filter(
(placement) => placement.id !== active.id && doGridPlacementsOverlap(placement, nextActive),
);
const displaced = collisions.length === 1 ? collisions[0] : undefined;
const swapped = displaced
? grid.placements.map((placement) => {
if (placement.id === active.id) return { ...placement, x: nextActive.x, y: nextActive.y };
if (placement.id === active.id) return { ...placement, x: displaced.x, y: displaced.y };
if (placement.id === displaced.id) return { ...placement, x: active.x, y: active.y };
return { ...placement };
})
@@ -8,6 +8,8 @@ export {
} from "./engine";
export { getPointerProjectedShape, getSnappedGridCoordinates, getSnappedGridDelta } from "./coordinates";
export type { DragProjectionOrigin } from "./coordinates";
export { getPreferredNestedExitTargetId } from "./targeting";
export type { GridTargetGeometry } from "./targeting";
export type {
BeginGridTransactionInput,
GridMoveGuard,
@@ -0,0 +1,68 @@
import { describe, expect, test } from "vitest";
import type { GridTargetGeometry } from "./targeting";
import { getPreferredNestedExitTargetId } from "./targeting";
const grid = (
id: string,
rectangle: GridTargetGeometry["rectangle"],
parentGridId: string | null = null,
): GridTargetGeometry => ({ id, parentGridId, rectangle });
const alwaysEligible = () => true;
describe("nested grid exit targeting", () => {
const grids = [
grid("canvas", { left: 0, top: 0, width: 1200, height: 800 }),
grid("container", { left: 200, top: 100, width: 600, height: 500 }, "canvas"),
];
test("keeps the nested grid while most of a large item remains inside", () => {
expect(
getPreferredNestedExitTargetId({
grids,
pointerTargetId: "container",
sourceGridId: "container",
dragShape: { left: 100, top: 150, width: 400, height: 200 },
isEligible: alwaysEligible,
}),
).toBe("container");
});
test("selects the parent before the pointer leaves when most of the item is outside", () => {
expect(
getPreferredNestedExitTargetId({
grids,
pointerTargetId: "container",
sourceGridId: "container",
dragShape: { left: 0, top: 150, width: 400, height: 200 },
isEligible: alwaysEligible,
}),
).toBe("canvas");
});
test("preserves a sibling container selected by the pointer", () => {
const withSibling = [...grids, grid("sibling", { left: 820, top: 100, width: 300, height: 500 }, "canvas")];
expect(
getPreferredNestedExitTargetId({
grids: withSibling,
pointerTargetId: "sibling",
sourceGridId: "container",
dragShape: { left: 820, top: 150, width: 300, height: 200 },
isEligible: alwaysEligible,
}),
).toBe("sibling");
});
test("does not escape to an ineligible parent", () => {
expect(
getPreferredNestedExitTargetId({
grids,
pointerTargetId: "container",
sourceGridId: "container",
dragShape: { left: 0, top: 150, width: 400, height: 200 },
isEligible: (gridId) => gridId !== "canvas",
}),
).toBe("container");
});
});
@@ -0,0 +1,90 @@
interface RectangleLike {
left: number;
top: number;
width: number;
height: number;
}
export interface GridTargetGeometry {
id: string;
parentGridId?: string | null;
rectangle: RectangleLike;
}
interface PreferredNestedExitTargetInput {
grids: readonly GridTargetGeometry[];
pointerTargetId: string;
sourceGridId: string;
dragShape: RectangleLike;
isEligible: (gridId: string) => boolean;
}
const NESTED_EXIT_RETAINED_AREA = 0.7;
/**
* Keeps pointer precision for entering containers, but lets a large item leave
* once a clear portion of its projected shape is visibly outside.
*/
export const getPreferredNestedExitTargetId = ({
grids,
pointerTargetId,
sourceGridId,
dragShape,
isEligible,
}: PreferredNestedExitTargetInput) => {
const byId = new Map(grids.map((grid) => [grid.id, grid]));
const source = byId.get(sourceGridId);
let current = byId.get(pointerTargetId);
if (!source || !current || !isSameGridOrAncestor(current.id, source, byId)) return pointerTargetId;
while (current.parentGridId) {
if (getCoveredAreaRatio(dragShape, current.rectangle) > NESTED_EXIT_RETAINED_AREA) break;
const parent = byId.get(current.parentGridId);
if (!parent || !isEligible(parent.id) || !containsCenter(parent.rectangle, dragShape)) break;
current = parent;
}
return current.id;
};
const isSameGridOrAncestor = (
candidateId: string,
source: GridTargetGeometry,
byId: ReadonlyMap<string, GridTargetGeometry>,
) => {
let current: GridTargetGeometry | undefined = source;
const visited = new Set<string>();
while (current) {
if (current.id === candidateId) return true;
if (!current.parentGridId || visited.has(current.id)) return false;
visited.add(current.id);
current = byId.get(current.parentGridId);
}
return false;
};
const getCoveredAreaRatio = (shape: RectangleLike, target: RectangleLike) => {
const shapeArea = shape.width * shape.height;
if (!Number.isFinite(shapeArea) || shapeArea <= 0) return 1;
const width = Math.max(
0,
Math.min(shape.left + shape.width, target.left + target.width) - Math.max(shape.left, target.left),
);
const height = Math.max(
0,
Math.min(shape.top + shape.height, target.top + target.height) - Math.max(shape.top, target.top),
);
return (width * height) / shapeArea;
};
const containsCenter = (target: RectangleLike, shape: RectangleLike) => {
const center = { x: shape.left + shape.width / 2, y: shape.top + shape.height / 2 };
return (
center.x >= target.left &&
center.x <= target.left + target.width &&
center.y >= target.top &&
center.y <= target.top + target.height
);
};
@@ -195,9 +195,6 @@ export const FixedGridItem = ({
onBlur={isEditorActive ? handleBlur : undefined}
onKeyDown={isEditorActive ? handleKeyboard : undefined}
>
{isEditorActive && (
<span className={classes.dragAffordance} data-testid="board-grid-drag-affordance" aria-hidden />
)}
{children}
{isEditorActive && (
<span id={instructionsId} className={classes.liveRegion}>
@@ -2,10 +2,6 @@
position: absolute;
inset: 0 auto auto 0;
overflow: visible;
background-image:
linear-gradient(to right, color-mix(in srgb, var(--mantine-color-dimmed) 18%, transparent) 1px, transparent 1px),
linear-gradient(to bottom, color-mix(in srgb, var(--mantine-color-dimmed) 18%, transparent) 1px, transparent 1px);
background-size: var(--board-grid-pitch) var(--board-grid-pitch);
border-radius: var(--mantine-radius-md);
}
@@ -169,11 +165,11 @@ body[data-board-grid-interacting] .board-grid-placeholder {
}
.board-grid-resize-handle[data-grid-resize-handle="n"] {
top: -12px;
top: 0;
}
.board-grid-resize-handle[data-grid-resize-handle="s"] {
bottom: -12px;
bottom: 0;
}
.board-grid-resize-handle[data-grid-resize-handle="e"],
@@ -185,11 +181,11 @@ body[data-board-grid-interacting] .board-grid-placeholder {
}
.board-grid-resize-handle[data-grid-resize-handle="e"] {
right: -12px;
right: 0;
}
.board-grid-resize-handle[data-grid-resize-handle="w"] {
left: -12px;
left: 0;
}
.board-grid-resize-handle[data-grid-resize-handle="e"]::after,
@@ -208,26 +204,26 @@ body[data-board-grid-interacting] .board-grid-placeholder {
}
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
top: -12px;
right: -12px;
top: 0;
right: 0;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="se"] {
right: -12px;
bottom: -12px;
right: 0;
bottom: 0;
cursor: nwse-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="sw"] {
bottom: -12px;
left: -12px;
bottom: 0;
left: 0;
cursor: nesw-resize;
}
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
top: -12px;
left: -12px;
top: 0;
left: 0;
cursor: nwse-resize;
}
@@ -36,6 +36,7 @@ import {
beginGridTransaction,
cancelGridTransaction,
commitGridTransaction,
getPreferredNestedExitTargetId,
getPointerProjectedShape,
getSnappedGridCoordinates,
getSnappedGridDelta,
@@ -51,13 +52,11 @@ import { useGridLayoutActions } from "./use-grid-layout-actions";
const GRID_ENTRY_TYPE = "homarr-board-grid-entry";
const GRID_TARGET_TYPE = "homarr-board-grid-target";
const EDIT_ROW_BUFFER = 8;
interface GridEditorProps {
sectionId: string;
columnCount: number;
rowCount: number;
fixedRowCount: boolean;
placements: readonly SectionGridPlacement[];
className: string;
}
@@ -286,18 +285,11 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const updateDragPreview = useCallback(
(
operation: DragMoveEvent["operation"],
projectedShape?: { left: number; top: number },
projectedShape?: { left: number; top: number; width: number; height: number },
targetOverride?: RegisteredGrid | null,
) => {
const active = activeRef.current;
const source = operation.source;
const targetData = operation.target ? getGridTargetData(operation.target) : null;
const targetGrid =
targetOverride === undefined
? targetData
? gridsRef.current.get(targetData.sectionId)
: undefined
: (targetOverride ?? undefined);
if (!active || active.mode !== "drag") return;
const publishPreview = (preview: Omit<GridInteraction, "previewRevision">) => {
@@ -321,7 +313,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
});
};
if (!source || !targetGrid) {
if (!source) {
showInvalidPreview(null, "invalid:no-target");
return;
}
@@ -329,7 +321,24 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const sourceData = getGridEntryData(source);
const shape = projectedShape ?? operation.shape?.current.boundingRectangle;
if (!sourceData || !shape) {
showInvalidPreview(targetGrid.id, `invalid:${targetGrid.id}:shape`);
showInvalidPreview(null, "invalid:shape");
return;
}
const targetData = operation.target ? getGridTargetData(operation.target) : null;
const pointerTargetGrid = targetData ? gridsRef.current.get(targetData.sectionId) : undefined;
const targetGrid =
targetOverride === undefined
? getPreferredRegisteredGridTarget(
gridsRef.current.values(),
sourceData.sectionId,
pointerTargetGrid,
shape,
(targetGridId) => isDropTargetEligible(source, targetGridId),
)
: (targetOverride ?? undefined);
if (!targetGrid) {
showInvalidPreview(null, "invalid:no-target");
return;
}
@@ -394,7 +403,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
valid: true,
});
},
[canAcceptDrop],
[canAcceptDrop, isDropTargetEligible],
);
const commitActiveTransaction = useCallback(
@@ -507,11 +516,12 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
if (!canceled) {
const source = operation.source;
const pointer = getPointerCoordinates(source?.element, nativeEvent);
const active = activeRef.current;
const finalShape = getProjectedDragShape(operation, active?.dragProjection ?? null, pointer);
const finalTarget =
source && pointer
? getPointerTargetGrid(gridsRef.current.values(), source, pointer, isDropTargetEligible)
? getPointerTargetGrid(gridsRef.current.values(), source, pointer, finalShape, isDropTargetEligible)
: undefined;
const active = activeRef.current;
// The rendered placeholder is the drop contract. During auto-scroll,
// dnd-kit's final geometry can advance between the last collision pass
@@ -703,14 +713,7 @@ const GridCollisionSynchronizer = ({ interaction }: { interaction: GridInteracti
};
/** A section-local dnd-kit canvas registered with the board transaction. */
export default function GridEditor({
sectionId,
columnCount,
rowCount,
fixedRowCount,
placements,
className,
}: GridEditorProps) {
export default function GridEditor({ sectionId, columnCount, rowCount, placements, className }: GridEditorProps) {
const t = useI18n();
const { section, items, innerSections } = useSectionContext();
const { interaction, registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditor();
@@ -740,14 +743,9 @@ export default function GridEditor({
interaction?.mode === "drag" && interaction.valid && interaction.targetGridId === sectionId
? interaction.targetPlacement
: null;
const renderedRowCount = fixedRowCount
? rowCount
: interaction
? Math.max(
rowCount + EDIT_ROW_BUFFER,
getLayoutRowCount(previewGrid?.placements ?? placements) + EDIT_ROW_BUFFER,
targetPlacement ? targetPlacement.y + targetPlacement.h + EDIT_ROW_BUFFER : 0,
)
const renderedRowCount =
section.kind !== "container" && interaction?.mode === "resize"
? Math.max(rowCount, getLayoutRowCount(renderPlacements))
: rowCount;
const { ref: droppableRef, isDropTarget } = useDroppable<GridTargetData>({
@@ -761,6 +759,7 @@ export default function GridEditor({
// through to an accepting parent grid underneath the pointer.
accept: (source) => isDropTargetEligible(source, sectionId),
});
const isIntentTarget = interaction?.mode === "drag" ? interaction.targetGridId === sectionId : isDropTarget;
const setGridRef = useCallback(
(element: HTMLDivElement | null) => {
@@ -776,24 +775,14 @@ export default function GridEditor({
return registerGrid({
id: sectionId,
columnCount,
maxRowCount: fixedRowCount ? rowCount : null,
maxRowCount: section.kind === "container" ? rowCount : null,
parentGridId,
ownerPlacementId,
placements,
depth,
element,
});
}, [
columnCount,
depth,
fixedRowCount,
ownerPlacementId,
parentGridId,
placements,
registerGrid,
rowCount,
sectionId,
]);
}, [columnCount, depth, ownerPlacementId, parentGridId, placements, registerGrid, rowCount, section.kind, sectionId]);
useLayoutEffect(() => {
const viewport = gridRef.current?.parentElement;
@@ -810,8 +799,8 @@ export default function GridEditor({
className={className}
data-grid-section-id={sectionId}
data-grid-depth={depth}
data-dnd-drop-target={isDropTarget ? "true" : "false"}
data-dnd-drop-valid={isDropTarget ? String(targetPlacement !== null) : undefined}
data-dnd-drop-target={isIntentTarget ? "true" : "false"}
data-dnd-drop-valid={isIntentTarget ? String(targetPlacement !== null) : undefined}
data-dnd-preview-revision={interaction?.targetGridId === sectionId ? interaction.previewRevision : undefined}
style={
{
@@ -1097,7 +1086,7 @@ const getProjectedDragShape = (
) => {
if (origin && pointer) return getPointerProjectedShape(origin, pointer);
const shape = operation.shape?.current.boundingRectangle;
return shape ? { left: shape.left, top: shape.top } : undefined;
return shape ? { left: shape.left, top: shape.top, width: shape.width, height: shape.height } : undefined;
};
const getInitialDragProjection = (
@@ -1112,6 +1101,8 @@ const getInitialDragProjection = (
initialShape: {
left: rectangle.left * frame.scaleX + frame.x,
top: rectangle.top * frame.scaleY + frame.y,
width: rectangle.width * frame.scaleX,
height: rectangle.height * frame.scaleY,
},
};
};
@@ -1150,9 +1141,11 @@ const getPointerTargetGrid = (
grids: Iterable<RegisteredGrid>,
source: AbstractDraggable,
pointer: { x: number; y: number },
projectedShape: { left: number; top: number; width: number; height: number } | undefined,
isTargetEligible: (source: AbstractDraggable, targetGridId: string) => boolean,
) =>
Array.from(grids)
) => {
const registeredGrids = Array.from(grids);
const pointerTarget = registeredGrids
.filter((grid) => {
const rectangle = grid.element.getBoundingClientRect();
return (
@@ -1170,7 +1163,41 @@ const getPointerTargetGrid = (
const firstRectangle = first.element.getBoundingClientRect();
const secondRectangle = second.element.getBoundingClientRect();
return firstRectangle.width * firstRectangle.height - secondRectangle.width * secondRectangle.height;
})[0] ?? null;
})[0];
const sourceData = getGridEntryData(source);
if (!pointerTarget || !projectedShape || !sourceData) return pointerTarget ?? null;
return getPreferredRegisteredGridTarget(
registeredGrids,
sourceData.sectionId,
pointerTarget,
projectedShape,
(targetGridId) => isTargetEligible(source, targetGridId),
);
};
const getPreferredRegisteredGridTarget = (
grids: Iterable<RegisteredGrid>,
sourceGridId: string,
pointerTarget: RegisteredGrid | undefined,
dragShape: { left: number; top: number; width: number; height: number },
isEligible: (gridId: string) => boolean,
) => {
if (!pointerTarget) return undefined;
const registeredGrids = Array.from(grids);
const preferredId = getPreferredNestedExitTargetId({
grids: registeredGrids.map((grid) => ({
id: grid.id,
parentGridId: grid.parentGridId,
rectangle: grid.element.getBoundingClientRect(),
})),
pointerTargetId: pointerTarget.id,
sourceGridId,
dragShape,
isEligible,
});
return registeredGrids.find((grid) => grid.id === preferredId) ?? pointerTarget;
};
const doesPreviewDisplaceNestedGridOwner = (
snapshot: TransactionalGridState<SectionGridPlacement>,
@@ -43,46 +43,6 @@
border-radius: var(--mantine-radius-md);
}
.dragAffordance {
position: absolute;
top: 6px;
left: 50%;
z-index: 7;
display: grid;
width: 28px;
height: 18px;
color: var(--mantine-color-dimmed);
pointer-events: none;
place-items: center;
background-color: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-xl);
opacity: 0.8;
transform: translateX(-50%);
transition:
color 160ms ease,
background-color 160ms ease,
border-color 160ms ease,
opacity 160ms ease;
}
.dragAffordance::before {
width: 12px;
height: 8px;
content: "";
background-image: radial-gradient(circle, currentColor 1.25px, transparent 1.5px);
background-size: 6px 4px;
}
.editorEntry:hover .dragAffordance,
.editorEntry:focus-visible .dragAffordance,
.editorEntry[data-keyboard-editing="true"] .dragAffordance {
color: var(--mantine-primary-color-filled);
background-color: var(--mantine-color-body);
border-color: var(--mantine-primary-color-filled);
opacity: 1;
}
.editorLoading {
position: absolute;
inset: 0;
@@ -109,8 +69,7 @@
}
@media (prefers-reduced-motion: reduce) {
.staticItem,
.dragAffordance {
.staticItem {
transition: none;
}
}
@@ -146,7 +146,6 @@ export const SectionGrid = ({
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
const rowCount = Math.max(contentRowCount, requestedRowCount);
const fixedRowCount = section.kind === "container";
const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
const canvasAttributes = isEditMode
@@ -161,7 +160,7 @@ export const SectionGrid = ({
innerSections: displayedInnerSections,
integrations,
columnCount,
maxRowCount: fixedRowCount ? rowCount : null,
maxRowCount: section.kind === "container" ? rowCount : null,
announce,
}}
>
@@ -179,7 +178,6 @@ export const SectionGrid = ({
sectionId={section.id}
columnCount={columnCount}
rowCount={rowCount}
fixedRowCount={fixedRowCount}
placements={displayPlacements}
className={combineClasses("board-grid-editor", classes.editorGrid)}
/>
@@ -217,7 +217,7 @@ describe("fixed grid item behavior", () => {
);
});
test("uses the whole tile as the focusable move surface with a visual drag affordance", () => {
test("uses the whole tile as the focusable move surface without extra drag chrome", () => {
getWeatherMock().advancedOptions.title = " Balcony forecast ";
renderWeather(root);
@@ -226,7 +226,7 @@ describe("fixed grid item behavior", () => {
expect(entry.getAttribute("aria-label")).toBe("Balcony forecast, column 1, row 1");
expect(entry.tabIndex).toBe(0);
expect(entry.getAttribute("aria-describedby")).not.toBeNull();
expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).not.toBeNull();
expect(container.querySelector('[data-testid="board-grid-drag-affordance"]')).toBeNull();
});
test("renders a view-only 1x1 card at the fixed 200px logical size", () => {
@@ -275,8 +275,8 @@ describe("fixed grid item behavior", () => {
});
expect(displayed).toEqual([
{ ...persisted[0], h: 1 },
{ ...persisted[1], y: 1 },
{ ...persisted[0], h: 0.5 },
{ ...persisted[1], y: 0.5 },
]);
expect(persisted[0]?.h).toBe(4);
expect(persisted[1]?.y).toBe(4);
@@ -15,3 +15,9 @@
background-color: transparent !important;
border-color: transparent !important;
}
.collapsedContainerCard {
background-color: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
}
+120 -12
View File
@@ -102,9 +102,13 @@ describe("Board grid", () => {
await expect(railItem).toHaveAttribute("data-grid-x", "0");
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
await expect(containerSection).toHaveAttribute("data-grid-h", "2");
await expect(containerSection.getByText("Nested box", { exact: true })).toBeVisible();
const containerLabel = containerSection.locator("[data-board-container-label]");
await expect(containerLabel).toHaveText("Nested box");
await expect(nestedItem).toHaveAttribute("data-grid-x", "0");
await expect(belowItem).toHaveAttribute("data-grid-y", "2");
const containerLabelBox = await expectBoundingBoxAsync(containerLabel);
const nestedItemBox = await expectBoundingBoxAsync(nestedItem.locator("[data-grid-item-content]"));
expect(containerLabelBox.y + containerLabelBox.height).toBeLessThanOrEqual(nestedItemBox.y + 0.5);
const firstItem = page.locator(`[data-grid-item-id="${fixture.firstItemId}"]`);
const secondItem = page.locator(`[data-grid-item-id="${fixture.secondItemId}"]`);
@@ -118,7 +122,14 @@ describe("Board grid", () => {
const logicalTile = firstItem.locator("[data-grid-item-content]");
await expect(logicalTile).toHaveCount(1);
await expectFixedLogicalTileAsync(logicalTile);
await expect(logicalTile).toHaveCSS("overflow", "hidden");
await expect(canvas).toHaveAttribute("data-canvas-overflow", "false");
await expectDocumentNotHorizontallyScrollableAsync(page);
const nestedAppLink = nestedItem.locator('a[href="#board-app-opened"]');
await expect(nestedAppLink).toBeVisible();
await nestedAppLink.click();
await expect(page).toHaveURL(/#board-app-opened$/);
await page.evaluate(() => history.replaceState(null, "", `${location.pathname}${location.search}`));
const normalZoomTileBox = await expectBoundingBoxAsync(logicalTile);
const normalCanvasScale = await readCanvasScaleAsync(canvas);
const secondItemBox = await expectBoundingBoxAsync(secondItem);
@@ -172,9 +183,16 @@ describe("Board grid", () => {
exact: true,
});
await collapseContainer.click();
await expect(containerSection).toHaveAttribute("data-grid-h", "1");
await expect(belowItem).toHaveAttribute("data-grid-y", "1");
await expect(containerSection).toHaveAttribute("data-grid-h", "0.5");
await expect(belowItem).toHaveAttribute("data-grid-y", "0.5");
await expect(nestedItem).toHaveCount(0);
const collapsedControl = containerSection.getByRole("button", { name: "Expand: Nested box", exact: true });
await expect(collapsedControl).toBeVisible();
await expect(collapsedControl).toHaveAttribute("data-board-container-collapsed-control", "true");
const expectedCollapsedHeight = (logicalCellPitch * 0.5 - LOGICAL_GRID_GAP) * normalCanvasScale;
await expect
.poll(async () => (await expectBoundingBoxAsync(containerSection)).height)
.toBeCloseTo(expectedCollapsedHeight, 1);
await page
.getByRole("button", {
@@ -209,9 +227,7 @@ describe("Board grid", () => {
await expect(firstKeyboardEntry).not.toHaveAttribute("aria-pressed");
await expect(firstKeyboardEntry).not.toHaveAttribute("aria-grabbed");
await expectTargetSizeAsync(firstItem);
const dragAffordance = firstItem.getByTestId("board-grid-drag-affordance");
await expect(dragAffordance).toBeVisible();
await expect(dragAffordance).toHaveCSS("pointer-events", "none");
await expect(firstItem.getByTestId("board-grid-drag-affordance")).toHaveCount(0);
const firstResizeHandles = firstItem.locator(":scope > [data-grid-resize-handle]");
await expect(firstResizeHandles).toHaveCount(8);
expect(
@@ -259,6 +275,7 @@ describe("Board grid", () => {
const mainGrid = page.locator(`[data-grid-section-id="${fixture.sectionId}"]`);
const interactionSnapshot = await readEditorGridSnapshotAsync(mainGrid);
await expectGridBoundaryRejectsOverflowAsync(page, mainGrid, firstItem);
await firstItem.locator("[data-grid-item-content]").evaluate((element) => {
const iframe = document.createElement("iframe");
iframe.dataset.e2eDragIframe = "true";
@@ -295,10 +312,21 @@ describe("Board grid", () => {
await page.setViewportSize({ width: 1920, height: 1200 });
await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3);
await expect(canvas).toHaveAttribute("data-canvas-overflow", "false");
await expectDocumentNotHorizontallyScrollableAsync(page);
await expect.poll(async () => await readEditorGridSnapshotAsync(mainGrid)).toEqual(interactionSnapshot);
const settingsButton = firstItem.getByRole("button", { name: /^Settings for / });
await firstItem.hover();
const widgetHeader = firstItem.locator("[data-board-widget-header]");
await expect(widgetHeader).toBeVisible();
const settingsBox = await expectBoundingBoxAsync(settingsButton);
const widgetHeaderBox = await expectBoundingBoxAsync(widgetHeader);
const firstItemBox = await expectBoundingBoxAsync(firstItem);
expect(settingsBox.y).toBeGreaterThanOrEqual(firstItemBox.y);
expect(settingsBox.y + settingsBox.height).toBeLessThanOrEqual(firstItemBox.y + firstItemBox.height);
expect(settingsBox.y).toBeLessThan(widgetHeaderBox.y + widgetHeaderBox.height);
expect(settingsBox.y + settingsBox.height).toBeGreaterThan(widgetHeaderBox.y);
await expect(settingsButton).toHaveCSS("border-radius", "4px");
await page.mouse.move(settingsBox.x + settingsBox.width / 2, settingsBox.y + settingsBox.height / 2);
await page.mouse.down();
await page.mouse.move(settingsBox.x + settingsBox.width / 2 + 8, settingsBox.y + settingsBox.height / 2 + 8);
@@ -419,6 +447,37 @@ describe("Board grid", () => {
await packedSecondKeyboardEntry.press("Escape");
await expect(secondItem).toHaveAttribute("data-grid-h", "2");
const packedSecondBox = await expectBoundingBoxAsync(secondItem);
const initialMainGridBox = await expectBoundingBoxAsync(mainGrid);
await page.mouse.move(
packedSecondBox.x + packedSecondBox.width / 2,
packedSecondBox.y + packedSecondBox.height / 2,
);
await page.mouse.down();
await page.mouse.move(nestedGridBox.x + 1, packedSecondBox.y + packedSecondBox.height / 2, { steps: 8 });
await expect(mainGrid).toHaveAttribute("data-dnd-drop-target", "true");
await expect(nestedEditor).toHaveAttribute("data-dnd-drop-target", "false");
await page.mouse.move(
initialMainGridBox.x + logicalCellSize * canvasScale * 0.5,
initialMainGridBox.y + logicalCellSize * canvasScale * 0.5,
{ steps: 8 },
);
await page.mouse.up();
await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute(
"data-grid-section-id",
fixture.sectionId,
);
await dragLocatorToImmediateReleaseAsync(
page,
secondItem,
nestedGridBox.x + logicalCellPitch * canvasScale * 1.5,
nestedGridBox.y + logicalCellSize * canvasScale * 0.5,
);
await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute(
"data-grid-section-id",
fixture.containerSectionId,
);
const nestedSouthResizeHandle = nestedItem.locator(':scope > [data-grid-resize-handle="s"]');
await expect(containerSection).toHaveAttribute("data-grid-section-chrome-active", "false");
await expect(nestedSouthResizeHandle).not.toHaveAttribute("data-grid-resize-disabled");
@@ -458,10 +517,10 @@ describe("Board grid", () => {
await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(0);
await page.reload();
await expect(canvas).toHaveAttribute("data-board-hydrated", "true");
await expect(secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]")).toHaveAttribute(
"data-grid-section-id",
fixture.containerSectionId,
);
await expect(secondItem).toHaveCount(1);
const reloadedSecondItemGrid = secondItem.locator("xpath=ancestor::*[@data-grid-section-id][1]");
await expect(reloadedSecondItemGrid).toHaveCount(1);
await expect(reloadedSecondItemGrid).toHaveAttribute("data-grid-section-id", fixture.containerSectionId);
await expect(containerSection.locator("xpath=ancestor::aside[1]")).toHaveAttribute("data-board-gutter", "left");
await editToggle.click();
await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3);
@@ -620,6 +679,12 @@ describe("Board grid", () => {
gridLayoutsOverlap(await readGridLayoutAsync(containerSection), await readGridLayoutAsync(belowItem)),
).toBe(false);
// Auto-scroll needs real document overflow. Grow the canvas through an
// explicit resize instead of relying on edit mode to add empty rows.
await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale * 6);
await expect(containerSection).toHaveAttribute("data-grid-h", "9");
await expect(belowItem).toHaveAttribute("data-grid-y", "9");
await firstItem.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" }));
const autoScrollStartBox = await expectBoundingBoxAsync(firstItem);
const autoScrollStart = {
@@ -846,6 +911,7 @@ const seedBoardGridAsync = async (db: SqliteDatabase, creatorId: string) => {
const belowItemId = createId();
const nestedItemId = createId();
const railItemId = createId();
const nestedAppId = createId();
await db.insert(sqliteSchema.boards).values({
id: boardId,
@@ -926,6 +992,12 @@ const seedBoardGridAsync = async (db: SqliteDatabase, creatorId: string) => {
height: 2,
},
]);
await db.insert(sqliteSchema.apps).values({
id: nestedAppId,
name: "Interactive nested app",
iconUrl: "/favicon.ico",
href: "#board-app-opened",
});
await db.insert(sqliteSchema.items).values([
{
id: firstItemId,
@@ -948,8 +1020,15 @@ const seedBoardGridAsync = async (db: SqliteDatabase, creatorId: string) => {
{
id: nestedItemId,
boardId,
kind: "clock",
options: stringifySuperJSON({ is24HourFormat: true }),
kind: "app",
options: stringifySuperJSON({
appId: nestedAppId,
descriptionDisplayMode: "hidden",
layout: "column",
openInNewTab: false,
pingEnabled: false,
showTitle: true,
}),
},
{
id: railItemId,
@@ -1536,6 +1615,26 @@ const expectImmediateOutsideReleaseRestoresSnapshotAsync = async (page: Page, gr
await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot);
};
const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator, locator: Locator) => {
const snapshot = await readEditorGridSnapshotAsync(grid);
const gridBox = await expectBoundingBoxAsync(grid);
const itemBox = await expectBoundingBoxAsync(locator);
const start = { x: itemBox.x + itemBox.width * 0.63, y: itemBox.y + itemBox.height * 0.42 };
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(start.x + 8, start.y);
await expect(locator).toHaveAttribute("data-dnd-drag-source", "true");
await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch, { steps: 12 });
await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(0);
const activeGridBox = await expectBoundingBoxAsync(grid);
expect(activeGridBox.height).toBeCloseTo(gridBox.height, 1);
await page.mouse.up();
await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting");
await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot);
};
const expectInvalidNestedDropRestoresSnapshotAsync = async (
page: Page,
sourceGrid: Locator,
@@ -1849,6 +1948,15 @@ const expectHorizontalOverflowAsync = async (locator: Locator) => {
expect(dimensions.scrollWidth).toBeGreaterThan(dimensions.clientWidth);
};
const expectDocumentNotHorizontallyScrollableAsync = async (page: Page) => {
await expect
.poll(
async () =>
await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth),
)
.toBeLessThanOrEqual(1);
};
const expectTargetSizeAsync = async (locator: Locator) => {
const box = await expectBoundingBoxAsync(locator);
expect(box.width).toBeGreaterThanOrEqual(24);