fix: polish board grid interactions

This commit is contained in:
Thomas Camlong committed 2026-08-02 09:36:33 +02:00
1 parent 5ddbf184fd
commit e3f679237d
30 files changed
+541 -199

No files matched your search

+5 -4
View File
@@ -106,7 +106,8 @@ When adding a new layout, its initial positions and sizes are generated from the
New items use their widget's default grid size, while a new Container starts at a useful `4 × 3` size when the layout has enough columns.
Both are placed in the first available position.
Moving content downward in edit mode extends the canvas automatically, while view mode trims it back to the content.
The current board edge is a hard drag boundary, so moving a tile outside the canvas cancels that placement instead of creating empty rows.
Adding content can still create the rows required for the new tile.
Nested Containers keep their own resizable bounds.
#### Name
@@ -143,14 +144,14 @@ Once the minimum of the next layout is reached, the layout will change.
View mode renders a static board without executing the drag-and-drop editor.
After the board is interactive, Homarr prepares the editor in the background for users who can modify it.
Entering edit mode keeps the static board visible until that preparation finishes, then enables collision-aware movement and resizing without changing the board geometry.
Widget content is inert in edit mode, so dragging anywhere on the content moves the tile instead of activating the widget; the visible grip and tile outline indicate the active move surface, while the tile's settings button remains available.
Widget content is inert in edit mode, so dragging anywhere on the content moves the tile instead of activating the widget; its settings button remains visible in the compact header above the tile.
Dropping a tile into another tile's exact slot swaps their positions when both resulting placements fit without overlapping other content.
For other collisions, or when a tile grows into occupied space, nearby tiles are compacted out of the way.
The visible corner controls provide a 44-pixel resize target, and edge controls appear on hover for horizontal or vertical resizing.
Resize handles stop at the tile's minimum allowed size while the pointer is still moving.
Exit edit mode to save the resulting positions.
Pointer users can drag anywhere on a tile; the grip is a visual cue for that move surface rather than a separate control.
Pointer users can drag anywhere on a widget tile. Containers use the grab button at their top-left corner.
Resize tiles from any edge or corner handle.
On a touch screen, press and hold the tile before moving it; resize handles support direct touch input.
Keyboard users can focus a tile, press Enter or Space to start editing, use the arrow keys to move it, and use Shift plus the arrow keys to resize it.
@@ -179,7 +180,7 @@ Each Container can configure:
- Whether its menu shows **Open all in new tabs** for the app links it contains.
- Its border color and custom CSS classes.
Collapsing a Container compacts the surrounding layout without losing its expanded size.
Collapsing a Container reduces it to a half-row expansion pill and compacts the surrounding layout without losing its expanded size.
Expanding it restores those bounds and moves following items out of the way.
### Background
@@ -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;
@@ -98,7 +98,7 @@ export const ClientBoard = () => {
.map(getLogicalTrackSize);
const logicalWidth =
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId) + LOGICAL_GRID_GAP;
const initialAvailableWidth = Math.max(1, initialViewportWidth - APP_SHELL_INLINE_PADDING);
const gridTemplateColumns = [
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
@@ -121,7 +121,11 @@ export const ClientBoard = () => {
>
<BoardGridEditorBoundary key={currentLayoutId}>
<BoardGridPortalRenderer />
<div ref={columnsRef} className={classes.columns} style={{ gridTemplateColumns }}>
<div
ref={columnsRef}
className={classes.columns}
style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}
>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
@@ -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;
}
@@ -30,9 +30,8 @@ interface BoardItemContentProps {
}
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
if (kind === "iframe") return "hidden";
if (kind === "systemResources") return "visible";
return undefined;
return "hidden";
};
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
@@ -125,7 +124,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
}}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
{isEditMode && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
{isEditMode && <BoardItemMenu item={newItem} resetErrorBoundary={resetErrorBoundary} />}
<div
className={itemContentClasses.editModeInertContent}
data-board-grid-inert-content={isEditMode ? "true" : undefined}
@@ -144,7 +143,7 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
}
/>
{isEditMode && <BoardItemMenu offset={4} item={newItem} />}
{isEditMode && <BoardItemMenu item={newItem} />}
<div
className={itemContentClasses.editModeInertContent}
data-board-grid-inert-content={isEditMode ? "true" : undefined}
@@ -16,7 +16,6 @@ import { useItemActions } from "./item-actions";
import { useOpenItemMoveModal } from "./item-move-modal";
interface BoardItemMenuProps {
offset: number;
item: SectionItem;
resetErrorBoundary?: () => void;
}
@@ -28,7 +27,7 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
return <BoardItemMenuInner {...props} />;
};
const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuProps) => {
const BoardItemMenuInner = ({ item, resetErrorBoundary }: BoardItemMenuProps) => {
const refResetErrorBoundaryOnNextRender = useRef(false);
const tItem = useScopedI18n("item");
const t = useI18n();
@@ -43,7 +42,6 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
const { section } = useSectionContext();
const settings = useSettings();
const label = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`);
const menuRightOffset = section.kind === "container" ? offset + 40 : offset;
// Reset error boundary on next render if item has been edited
useEffect(() => {
@@ -112,14 +110,16 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP
<Menu.Target>
<ActionIcon
variant="default"
radius={"xl"}
size={24}
radius="sm"
pos="absolute"
top={offset}
right={menuRightOffset}
top={12}
right={8}
style={{ zIndex: 10 }}
data-board-widget-settings
aria-label={tItem("menu.label.settingsFor", { name: label })}
>
<IconDotsVertical size={"1rem"} />
<IconDotsVertical size="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown miw={128}>
@@ -2,12 +2,11 @@
position: absolute;
left: 10px;
right: 10px;
bottom: calc(100% - 10px);
margin-bottom: 1px;
top: 10px;
z-index: 200;
pointer-events: none;
opacity: 0;
transform: translateY(6px);
transform: translateY(-6px);
transition:
opacity 150ms ease,
transform 150ms ease;
@@ -32,7 +31,7 @@
align-items: center;
gap: 8px;
min-height: 28px;
padding: 4px 8px;
padding: 4px 36px 4px 8px;
border: 1px solid var(--border-color);
border-radius: var(--mantine-radius-md);
background-color: var(--background-color);
@@ -33,7 +33,7 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
}
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);
@@ -5,6 +5,7 @@ import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize, normalizeG
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);