fix: polish board grid interactions
This commit is contained in:
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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user