perf: isolate board grid previews

This commit is contained in:
Thomas Camlong committed 2026-08-11 11:27:58 +02:00
1 parent a7ce15eaea
commit dc66493caa
8 files changed
+517 -100

No files matched your search

@@ -8,11 +8,13 @@
.board-grid-entry {
z-index: 1;
overflow: visible;
transition:
left 100ms ease,
top 100ms ease,
width 100ms ease,
height 100ms ease;
}
.board-grid-entry[data-grid-preview="true"] {
width: var(--board-grid-preview-width) !important;
height: var(--board-grid-preview-height) !important;
translate: var(--board-grid-preview-x) var(--board-grid-preview-y);
will-change: translate;
}
.board-grid-entry[data-dnd-active="true"],
@@ -1,6 +1,6 @@
"use client";
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren, RefObject } from "react";
import {
createContext,
memo,
@@ -63,6 +63,8 @@ import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
import { getGridDepth } from "./grid-depth";
import { createGridInteractionStore } from "./grid-interaction-store";
import { useBoardGridPortalHost } from "./grid-portal-host";
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
import { createGridResizeOutlineController } from "./grid-resize-outline";
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
@@ -772,27 +774,12 @@ export default function GridEditor({
}: GridEditorProps) {
const t = useI18n();
const { section, items, innerSections } = useSectionContext();
const interaction = useBoardGridEditorGridInteraction(sectionId);
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
const gridRef = useRef<HTMLDivElement>(null);
const entryElementsRef = useRef(new Map<string, HTMLElement>());
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
const depth = getDepth(sectionId);
const previewGrid = interaction?.state.grids.find((grid) => grid.id === sectionId);
const controlledById = useMemo(() => new Map(placements.map((placement) => [placement.id, placement])), [placements]);
const renderPlacements = useMemo(() => {
if (!previewGrid || !interaction) return placements;
if (interaction.sourceGridId === sectionId) {
if (interaction.mode === "resize") return previewGrid.placements;
const original = controlledById.get(interaction.activeId);
if (!original) return previewGrid.placements;
return previewGrid.placements.some((placement) => placement.id === interaction.activeId)
? previewGrid.placements.map((placement) => (placement.id === interaction.activeId ? original : placement))
: [...previewGrid.placements, original];
}
return previewGrid.placements.filter((placement) => placement.id !== interaction.activeId);
}, [controlledById, interaction, placements, previewGrid, sectionId]);
const labelById = useMemo(
() =>
new Map(
@@ -805,21 +792,7 @@ export default function GridEditor({
),
[innerSections, items, t],
);
const targetPlacement =
interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null;
const growsDuringDrag =
interaction?.mode === "drag" &&
(interaction.targetGridId === sectionId ||
(interaction.targetGridId === null && interaction.sourceGridId === sectionId));
const participatesInInteraction =
interaction && (interaction.sourceGridId === sectionId || interaction.targetGridId === sectionId);
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? renderPlacements);
const renderedRowCount =
maxRowCount === null && participatesInInteraction
? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0)
: rowCount;
const { ref: droppableRef, isDropTarget } = useDroppable<GridTargetData>({
const { ref: droppableRef } = useDroppable<GridTargetData>({
id: `board-grid:${sectionId}`,
type: GRID_TARGET_TYPE,
data: { kind: GRID_TARGET_TYPE, sectionId },
@@ -830,7 +803,6 @@ 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) => {
@@ -839,6 +811,10 @@ export default function GridEditor({
},
[droppableRef],
);
const registerEntryElement = useCallback((id: string, element: HTMLElement | null) => {
if (element) entryElementsRef.current.set(id, element);
else entryElementsRef.current.delete(id);
}, []);
useLayoutEffect(() => {
const element = gridRef.current;
@@ -855,33 +831,21 @@ export default function GridEditor({
});
}, [columnCount, depth, maxRowCount, ownerPlacementId, parentGridId, placements, registerGrid, sectionId]);
useLayoutEffect(() => {
const viewport = gridRef.current?.parentElement;
if (!viewport?.hasAttribute("data-section-id")) return;
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
return () => {
viewport.style.removeProperty("--board-grid-drag-height");
};
}, [renderedRowCount]);
return (
<div
ref={setGridRef}
className={className}
data-grid-section-id={sectionId}
data-grid-depth={depth}
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={
{
width: getLogicalTrackSize(columnCount),
height: getLogicalTrackSize(renderedRowCount),
height: getLogicalTrackSize(rowCount),
"--board-grid-pitch": `${LOGICAL_GRID_PITCH}px`,
} as CSSProperties
}
>
{renderPlacements.map((placement) => {
{placements.map((placement) => {
return (
<DndGridEntry
key={placement.id}
@@ -890,23 +854,18 @@ export default function GridEditor({
label={labelById.get(placement.id) ?? placement.id}
columnCount={columnCount}
maxRowCount={maxRowCount}
isActive={interaction?.activeId === placement.id}
registerElement={registerEntryElement}
/>
);
})}
{targetPlacement && (
<div
className="board-grid-placeholder"
style={getLogicalItemStyle(targetPlacement)}
data-grid-placeholder-for={interaction?.activeId}
data-grid-placeholder-mode={interaction?.mode}
data-grid-x={targetPlacement.x}
data-grid-y={targetPlacement.y}
data-grid-w={targetPlacement.w}
data-grid-h={targetPlacement.h}
aria-hidden="true"
/>
)}
<GridPreviewLayer
sectionId={sectionId}
rowCount={rowCount}
maxRowCount={maxRowCount}
placements={placements}
gridRef={gridRef}
entryElementsRef={entryElementsRef}
/>
<span
data-grid-runtime="homarr-dnd-kit-v1"
data-editor-section-id={sectionId}
@@ -917,23 +876,126 @@ export default function GridEditor({
);
}
interface GridPreviewLayerProps {
sectionId: string;
rowCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
gridRef: RefObject<HTMLDivElement | null>;
entryElementsRef: RefObject<Map<string, HTMLElement>>;
}
const GridPreviewLayer = ({
sectionId,
rowCount,
maxRowCount,
placements,
gridRef,
entryElementsRef,
}: GridPreviewLayerProps) => {
const interaction = useBoardGridEditorGridInteraction(sectionId);
const previewGrid = interaction?.state.grids.find((grid) => grid.id === sectionId);
const previewStateRef = useRef(createGridPreviewDomState());
const placementsRef = useRef(placements);
placementsRef.current = placements;
const targetPlacement =
interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null;
const growsDuringDrag =
interaction?.mode === "drag" &&
(interaction.targetGridId === sectionId ||
(interaction.targetGridId === null && interaction.sourceGridId === sectionId));
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? placements);
const renderedRowCount =
maxRowCount === null && interaction ? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0) : rowCount;
useLayoutEffect(() => {
previewStateRef.current = syncGridPreviewGeometry({
elements: entryElementsRef.current,
placements,
previewPlacements: previewGrid?.placements ?? null,
activeId: interaction?.activeId ?? null,
mode: interaction?.mode ?? null,
previous: previewStateRef.current,
});
const grid = gridRef.current;
if (!grid) return;
const isIntentTarget = interaction?.mode === "drag" && interaction.targetGridId === sectionId;
setOptionalAttribute(grid, "data-dnd-drop-target", isIntentTarget ? "true" : "false");
setOptionalAttribute(grid, "data-dnd-drop-valid", isIntentTarget ? String(targetPlacement !== null) : null);
setOptionalAttribute(
grid,
"data-dnd-preview-revision",
interaction?.targetGridId === sectionId ? String(interaction.previewRevision) : null,
);
grid.style.height = `${getLogicalTrackSize(renderedRowCount)}px`;
const viewport = grid.parentElement;
if (viewport?.hasAttribute("data-section-id")) {
viewport.style.setProperty("--board-grid-drag-height", `${getLogicalTrackSize(renderedRowCount)}px`);
}
}, [
entryElementsRef,
gridRef,
interaction,
placements,
previewGrid?.placements,
renderedRowCount,
sectionId,
targetPlacement,
]);
useLayoutEffect(
() => () => {
clearGridPreviewGeometry(entryElementsRef.current, placementsRef.current, previewStateRef.current);
const grid = gridRef.current;
if (!grid) return;
grid.removeAttribute("data-dnd-drop-target");
grid.removeAttribute("data-dnd-drop-valid");
grid.removeAttribute("data-dnd-preview-revision");
grid.parentElement?.style.removeProperty("--board-grid-drag-height");
},
[entryElementsRef, gridRef],
);
return targetPlacement ? (
<div
className="board-grid-placeholder"
style={getLogicalItemStyle(targetPlacement)}
data-grid-placeholder-for={interaction?.activeId}
data-grid-placeholder-mode={interaction?.mode}
data-grid-x={targetPlacement.x}
data-grid-y={targetPlacement.y}
data-grid-w={targetPlacement.w}
data-grid-h={targetPlacement.h}
aria-hidden="true"
/>
) : null;
};
const DndGridEntryComponent = ({
sectionId,
placement,
label,
columnCount,
maxRowCount,
isActive,
registerElement,
}: DndGridEntryProps) => {
const { acquireContainer } = useBoardGridPortalHost();
const { beginResize, previewResize, completeResize, cancelResize } = useBoardGridEditorActions();
const [isSectionChromeActive, setIsSectionChromeActive] = useState(false);
const [continuousResize, setContinuousResize] = useState<{
placement: SectionGridPlacement;
valid: boolean;
} | null>(null);
const mountRef = useRef<HTMLDivElement>(null);
const shellRef = useRef<HTMLDivElement>(null);
const placementRef = useRef(placement);
placementRef.current = placement;
const resizeOutlineController = useMemo(
() =>
createGridResizeOutlineController(
() => shellRef.current,
() => placementRef.current,
),
[],
);
const sourceData = useMemo<GridEntryData>(
() => ({ kind: GRID_ENTRY_TYPE, sectionId, placement, label }),
[label, placement, sectionId],
@@ -973,11 +1035,14 @@ const DndGridEntryComponent = ({
const setShellRef = useCallback(
(element: HTMLDivElement | null) => {
shellRef.current = element;
registerElement(placement.id, element);
draggableRef(element);
},
[draggableRef],
[draggableRef, placement.id, registerElement],
);
useEffect(() => () => resizeOutlineController.destroy(), [resizeOutlineController]);
useLayoutEffect(() => {
const mount = mountRef.current;
if (!mount) return;
@@ -1020,21 +1085,11 @@ const DndGridEntryComponent = ({
return () => shell.removeEventListener("pointerdown", handlePointerDownCapture, { capture: true });
}, [placement.type]);
const style = getLogicalItemStyle(placement) as CSSProperties;
const continuousResizeStyle =
continuousResize && isActive
? getLogicalItemStyle({
...continuousResize.placement,
x: continuousResize.placement.x - placement.x,
y: continuousResize.placement.y - placement.y,
})
: undefined;
return (
<div
ref={setShellRef}
className="board-grid-entry"
style={style}
style={getLogicalItemStyle(placement) as CSSProperties}
data-grid-item-id={placement.id}
data-grid-item-type={placement.type}
data-grid-x={placement.x}
@@ -1042,20 +1097,10 @@ const DndGridEntryComponent = ({
data-grid-w={placement.w}
data-grid-h={placement.h}
data-grid-section-chrome-active={placement.type === "section" ? String(isSectionChromeActive) : undefined}
data-dnd-active={isActive ? "true" : undefined}
data-dnd-drag-source={isDragging ? "true" : undefined}
data-dnd-dropping={isDropping ? "true" : undefined}
>
<div ref={mountRef} className="board-grid-content-mount" />
{continuousResizeStyle && (
<div
className="board-grid-resize-outline"
style={continuousResizeStyle}
data-grid-resize-outline
data-grid-resize-valid={String(continuousResize?.valid)}
aria-hidden="true"
/>
)}
<GridResizeHandles
sectionId={sectionId}
placement={placement}
@@ -1067,7 +1112,7 @@ const DndGridEntryComponent = ({
previewResize={previewResize}
completeResize={completeResize}
cancelResize={cancelResize}
setContinuousResize={setContinuousResize}
setContinuousResize={resizeOutlineController.schedule}
/>
</div>
);
@@ -1280,6 +1325,11 @@ const getAncestorCssZoom = (element: Element): number => {
const arePointerCoordinatesEqual = (first: { x: number; y: number } | null, second: { x: number; y: number } | null) =>
first !== null && second !== null && Math.abs(first.x - second.x) <= 0.5 && Math.abs(first.y - second.y) <= 0.5;
const setOptionalAttribute = (element: HTMLElement, name: string, value: string | null) => {
if (value === null) element.removeAttribute(name);
else element.setAttribute(name, value);
};
const getPointerTargetGrid = (
grids: Iterable<RegisteredGrid>,
source: AbstractDraggable,
@@ -6,7 +6,7 @@ export interface DndGridEntryProps {
label: string;
columnCount: number;
maxRowCount: number | null;
isActive: boolean;
registerElement: (id: string, element: HTMLElement | null) => void;
}
export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: DndGridEntryProps) =>
@@ -14,7 +14,7 @@ export const areDndGridEntryPropsEqual = (previous: DndGridEntryProps, next: Dnd
previous.label === next.label &&
previous.columnCount === next.columnCount &&
previous.maxRowCount === next.maxRowCount &&
previous.isActive === next.isActive &&
previous.registerElement === next.registerElement &&
previous.placement.id === next.placement.id &&
previous.placement.type === next.placement.type &&
previous.placement.x === next.placement.x &&
@@ -0,0 +1,101 @@
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
export interface GridPreviewDomState {
activeId: string | null;
changedIds: Set<string>;
}
interface SyncGridPreviewGeometryInput<TPlacement extends GridPlacement> {
elements: ReadonlyMap<string, HTMLElement>;
placements: readonly TPlacement[];
previewPlacements: readonly TPlacement[] | null;
activeId: string | null;
mode: "drag" | "resize" | null;
previous: GridPreviewDomState;
}
export const createGridPreviewDomState = (): GridPreviewDomState => ({
activeId: null,
changedIds: new Set(),
});
export const syncGridPreviewGeometry = <TPlacement extends GridPlacement>({
elements,
placements,
previewPlacements,
activeId,
mode,
previous,
}: SyncGridPreviewGeometryInput<TPlacement>): GridPreviewDomState => {
const controlledById = new Map(placements.map((placement) => [placement.id, placement]));
const changedIds = new Set<string>();
if (previewPlacements && mode) {
for (const preview of previewPlacements) {
const controlled = controlledById.get(preview.id);
const element = elements.get(preview.id);
if (!controlled || !element || (mode === "drag" && preview.id === activeId)) continue;
if (arePlacementsEqual(controlled, preview)) continue;
applyPreviewGeometry(element, controlled, preview);
changedIds.add(preview.id);
}
}
for (const id of previous.changedIds) {
if (changedIds.has(id)) continue;
const element = elements.get(id);
const controlled = controlledById.get(id);
if (element && controlled) clearPreviewGeometry(element, controlled);
}
if (previous.activeId && previous.activeId !== activeId) {
elements.get(previous.activeId)?.removeAttribute("data-dnd-active");
}
if (activeId) elements.get(activeId)?.setAttribute("data-dnd-active", "true");
return { activeId, changedIds };
};
export const clearGridPreviewGeometry = <TPlacement extends GridPlacement>(
elements: ReadonlyMap<string, HTMLElement>,
placements: readonly TPlacement[],
state: GridPreviewDomState,
) => {
const controlledById = new Map(placements.map((placement) => [placement.id, placement]));
for (const id of state.changedIds) {
const element = elements.get(id);
const controlled = controlledById.get(id);
if (element && controlled) clearPreviewGeometry(element, controlled);
}
if (state.activeId) elements.get(state.activeId)?.removeAttribute("data-dnd-active");
};
const applyPreviewGeometry = (element: HTMLElement, controlled: GridPlacement, preview: GridPlacement) => {
element.style.setProperty("--board-grid-preview-x", `${(preview.x - controlled.x) * LOGICAL_GRID_PITCH}px`);
element.style.setProperty("--board-grid-preview-y", `${(preview.y - controlled.y) * LOGICAL_GRID_PITCH}px`);
element.style.setProperty("--board-grid-preview-width", `${getLogicalTrackSize(preview.w)}px`);
element.style.setProperty("--board-grid-preview-height", `${getLogicalTrackSize(preview.h)}px`);
element.setAttribute("data-grid-preview", "true");
setPlacementAttributes(element, preview);
};
const clearPreviewGeometry = (element: HTMLElement, controlled: GridPlacement) => {
element.style.removeProperty("--board-grid-preview-x");
element.style.removeProperty("--board-grid-preview-y");
element.style.removeProperty("--board-grid-preview-width");
element.style.removeProperty("--board-grid-preview-height");
element.removeAttribute("data-grid-preview");
setPlacementAttributes(element, controlled);
};
const setPlacementAttributes = (element: HTMLElement, placement: GridPlacement) => {
element.dataset.gridX = String(placement.x);
element.dataset.gridY = String(placement.y);
element.dataset.gridW = String(placement.w);
element.dataset.gridH = String(placement.h);
};
const arePlacementsEqual = (first: GridPlacement, second: GridPlacement) =>
first.x === second.x && first.y === second.y && first.w === second.w && first.h === second.h;
@@ -0,0 +1,72 @@
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
interface ResizeOutlinePreview<TPlacement extends GridPlacement> {
placement: TPlacement;
valid: boolean;
}
export const createGridResizeOutlineController = <TPlacement extends GridPlacement>(
getShell: () => HTMLElement | null,
getControlledPlacement: () => TPlacement,
) => {
let frame = 0;
let pending: ResizeOutlinePreview<TPlacement> | null = null;
const remove = () => {
getShell()?.querySelector(":scope > [data-grid-resize-outline]")?.remove();
};
const flush = () => {
frame = 0;
const shell = getShell();
const preview = pending;
if (!shell || !preview) return;
const controlled = getControlledPlacement();
const visualX = toFiniteNumber(shell.dataset.gridX, controlled.x);
const visualY = toFiniteNumber(shell.dataset.gridY, controlled.y);
const outline =
shell.querySelector<HTMLElement>(":scope > [data-grid-resize-outline]") ?? createResizeOutline(shell);
outline.style.position = "absolute";
outline.style.left = `${(preview.placement.x - visualX) * LOGICAL_GRID_PITCH}px`;
outline.style.top = `${(preview.placement.y - visualY) * LOGICAL_GRID_PITCH}px`;
outline.style.width = `${getLogicalTrackSize(preview.placement.w)}px`;
outline.style.height = `${getLogicalTrackSize(preview.placement.h)}px`;
outline.dataset.gridResizeValid = String(preview.valid);
};
return {
schedule(preview: ResizeOutlinePreview<TPlacement> | null) {
pending = preview;
if (!preview) {
window.cancelAnimationFrame(frame);
frame = 0;
remove();
return;
}
if (frame !== 0) return;
frame = window.requestAnimationFrame(flush);
},
destroy() {
pending = null;
window.cancelAnimationFrame(frame);
frame = 0;
remove();
},
};
};
const toFiniteNumber = (value: string | undefined, fallback: number) => {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
};
const createResizeOutline = (shell: HTMLElement) => {
const outline = document.createElement("div");
outline.className = "board-grid-resize-outline";
outline.dataset.gridResizeOutline = "";
outline.setAttribute("aria-hidden", "true");
shell.append(outline);
return outline;
};
@@ -5,22 +5,37 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest";
import type { DndGridEntryProps } from "../grid-entry-memo";
import { areDndGridEntryPropsEqual } from "../grid-entry-memo";
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
describe("grid entry memoization", () => {
let container: HTMLDivElement;
let root: Root;
let renders: number;
let renders = 0;
let widgetRenders = 0;
const Probe = memo((props: DndGridEntryProps) => {
renders += 1;
return createElement("span", { "data-x": props.placement.x });
return createElement(
"span",
{
"data-grid-id": props.placement.id,
"data-x": props.placement.x,
ref: (element: HTMLSpanElement | null) => props.registerElement(props.placement.id, element),
},
createElement(WidgetProbe),
);
}, areDndGridEntryPropsEqual);
const WidgetProbe = () => {
widgetRenders += 1;
return null;
};
beforeEach(() => {
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
renders = 0;
widgetRenders = 0;
});
afterEach(() => {
@@ -61,10 +76,52 @@ describe("grid entry memoization", () => {
expect(renders).toBe(101);
});
test("keeps 100 entry and widget renders frozen while preview geometry changes", () => {
const elements = new Map<string, HTMLElement>();
const registerElement = (id: string, element: HTMLElement | null) => {
if (element) elements.set(id, element);
else elements.delete(id);
};
const entries = Array.from({ length: 100 }, (_, index) => ({
...createProps(),
registerElement,
placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) },
}));
renderBoard(entries);
expect(renders).toBe(100);
expect(widgetRenders).toBe(100);
let previewState = createGridPreviewDomState();
for (let revision = 1; revision <= 20; revision += 1) {
previewState = syncGridPreviewGeometry({
elements,
placements: entries.map((entry) => entry.placement),
previewPlacements: entries.map((entry, index) =>
index === 42 ? { ...entry.placement, y: entry.placement.y + revision } : entry.placement,
),
activeId: "item-0",
mode: "drag",
previous: previewState,
});
}
expect(renders).toBe(100);
expect(widgetRenders).toBe(100);
expect(elements.get("item-42")?.getAttribute("data-grid-preview")).toBe("true");
renderBoard(
entries.map((entry, index) =>
index === 42 ? { ...entry, placement: { ...entry.placement, y: entry.placement.y + 20 } } : entry,
),
);
expect(renders).toBe(101);
expect(widgetRenders).toBe(101);
});
test.each([
["active state", (props: DndGridEntryProps) => ({ ...props, isActive: true })],
["label", (props: DndGridEntryProps) => ({ ...props, label: "Changed" })],
["grid limits", (props: DndGridEntryProps) => ({ ...props, maxRowCount: 10 })],
["element registration", (props: DndGridEntryProps) => ({ ...props, registerElement: () => undefined })],
[
"minimum size",
(props: DndGridEntryProps) => ({
@@ -102,5 +159,5 @@ const createProps = (): DndGridEntryProps => ({
label: "Weather",
columnCount: 12,
maxRowCount: null,
isActive: false,
registerElement: () => undefined,
});
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, test } from "vitest";
import { createGridPreviewDomState, syncGridPreviewGeometry } from "../grid-preview-geometry";
describe("grid preview geometry", () => {
let elements: Map<string, HTMLElement>;
beforeEach(() => {
elements = new Map([
["active", createEntry("active", 0, 0, 2, 2)],
["displaced", createEntry("displaced", 2, 0, 1, 1)],
]);
});
test("moves displaced entries without moving the active drag source", () => {
const placements = [placement("active", 0, 0, 2, 2), placement("displaced", 2, 0, 1, 1)];
const state = syncGridPreviewGeometry({
elements,
placements,
previewPlacements: [placement("active", 2, 0, 2, 2), placement("displaced", 0, 2, 1, 1)],
activeId: "active",
mode: "drag",
previous: createGridPreviewDomState(),
});
expect(elements.get("active")?.hasAttribute("data-grid-preview")).toBe(false);
expect(elements.get("active")?.getAttribute("data-dnd-active")).toBe("true");
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-x")).toBe("-448px");
expect(elements.get("displaced")?.style.getPropertyValue("--board-grid-preview-y")).toBe("448px");
expect(elements.get("displaced")?.dataset.gridX).toBe("0");
expect(elements.get("displaced")?.dataset.gridY).toBe("2");
syncGridPreviewGeometry({
elements,
placements,
previewPlacements: null,
activeId: null,
mode: null,
previous: state,
});
expect(elements.get("active")?.hasAttribute("data-dnd-active")).toBe(false);
expect(elements.get("displaced")?.hasAttribute("data-grid-preview")).toBe(false);
expect(elements.get("displaced")?.dataset.gridX).toBe("2");
expect(elements.get("displaced")?.dataset.gridY).toBe("0");
});
test("applies snapped size and position to the active resize shell", () => {
syncGridPreviewGeometry({
elements,
placements: [placement("active", 0, 0, 2, 2)],
previewPlacements: [placement("active", 1, 1, 3, 4)],
activeId: "active",
mode: "resize",
previous: createGridPreviewDomState(),
});
const active = elements.get("active");
expect(active?.getAttribute("data-grid-preview")).toBe("true");
expect(active?.style.getPropertyValue("--board-grid-preview-x")).toBe("224px");
expect(active?.style.getPropertyValue("--board-grid-preview-y")).toBe("224px");
expect(active?.style.getPropertyValue("--board-grid-preview-width")).toBe("648px");
expect(active?.style.getPropertyValue("--board-grid-preview-height")).toBe("872px");
expect(active?.dataset.gridW).toBe("3");
expect(active?.dataset.gridH).toBe("4");
});
});
const placement = (id: string, x: number, y: number, w: number, h: number) => ({
id,
type: "item" as const,
x,
y,
w,
h,
});
const createEntry = (id: string, x: number, y: number, w: number, h: number) => {
const element = document.createElement("div");
element.dataset.gridItemId = id;
element.dataset.gridX = String(x);
element.dataset.gridY = String(y);
element.dataset.gridW = String(w);
element.dataset.gridH = String(h);
return element;
};
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { createGridResizeOutlineController } from "../grid-resize-outline";
describe("grid resize outline", () => {
afterEach(() => vi.restoreAllMocks());
test("coalesces pointer updates and removes the outline synchronously", () => {
const frame = { callback: undefined as FrameRequestCallback | undefined };
const requestFrame = vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
frame.callback = callback;
return 7;
});
const cancelFrame = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
const shell = document.createElement("div");
const controlled = placement(1, 2, 2, 2);
const controller = createGridResizeOutlineController(
() => shell,
() => controlled,
);
controller.schedule({ placement: placement(1, 2, 2, 2), valid: true });
controller.schedule({ placement: placement(1, 2, 1, 2), valid: false });
expect(requestFrame).toHaveBeenCalledTimes(1);
expect(shell.querySelector("[data-grid-resize-outline]")).toBeNull();
frame.callback?.(0);
const outline = shell.querySelector<HTMLElement>("[data-grid-resize-outline]");
expect(outline?.style.left).toBe("0px");
expect(outline?.style.top).toBe("0px");
expect(outline?.style.width).toBe("200px");
expect(outline?.style.height).toBe("424px");
expect(outline?.dataset.gridResizeValid).toBe("false");
controller.schedule(null);
expect(cancelFrame).toHaveBeenCalledWith(0);
expect(shell.querySelector("[data-grid-resize-outline]")).toBeNull();
controller.destroy();
});
});
const placement = (x: number, y: number, w: number, h: number) => ({
id: "item",
type: "item" as const,
x,
y,
w,
h,
});