fix: simplify grid interaction previews

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:07 +02:00
1 parent a2400f8412
commit 16982aa178
9 files changed
+341 -217

No files matched your search

@@ -0,0 +1,25 @@
export const observeGridDragHandle = (
container: HTMLElement,
selector: string,
bind: (element: HTMLElement | null) => void,
) => {
let observer: MutationObserver | null = null;
const bindFirstHandle = () => {
const handle = container.querySelector<HTMLElement>(selector);
if (!handle) return false;
bind(handle);
observer?.disconnect();
observer = null;
return true;
};
if (!bindFirstHandle()) {
observer = new MutationObserver(bindFirstHandle);
observer.observe(container, { childList: true, subtree: true });
}
return () => {
observer?.disconnect();
bind(null);
};
};
@@ -75,8 +75,8 @@
zoom: var(--board-grid-overlay-content-zoom, 1);
}
body[data-board-grid-interacting="drag"] .board-grid-entry,
body[data-board-grid-interacting="drag"] .board-grid-placeholder {
body[data-board-grid-interacting] .board-grid-entry,
body[data-board-grid-interacting] .board-grid-placeholder {
transition: none;
}
@@ -1,6 +1,6 @@
"use client";
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren, RefObject } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent, PropsWithChildren } from "react";
import {
createContext,
memo,
@@ -36,12 +36,7 @@ import { DragDropProvider, DragOverlay, useDragDropManager, useDraggable, useDro
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import {
getLayoutRowCount,
getLogicalItemStyle,
getLogicalTrackSize,
LOGICAL_GRID_PITCH,
} from "~/components/board/layout";
import { getLogicalItemStyle, getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
import { useSectionContext } from "../section-context";
import {
@@ -61,9 +56,11 @@ import type { DragProjectionOrigin, GridResizeDirection, GridTransaction, Transa
import type { DndGridEntryProps } from "./grid-entry-memo";
import { areDndGridEntryPropsEqual } from "./grid-entry-memo";
import { getGridDepth } from "./grid-depth";
import { observeGridDragHandle } from "./grid-drag-handle";
import { createGridInteractionStore } from "./grid-interaction-store";
import { useBoardGridPortalHost } from "./grid-portal-host";
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
import type { GridInteraction } from "./grid-preview-layer";
import { GridPreviewLayer } from "./grid-preview-layer";
import { createGridResizeOutlineController } from "./grid-resize-outline";
import type { CommitSectionGridInput, SectionGridPlacement } from "./use-grid-layout-actions";
import { useGridLayoutActions } from "./use-grid-layout-actions";
@@ -103,17 +100,6 @@ interface RegisteredGrid {
element: HTMLElement;
}
interface GridInteraction {
activeId: string;
sourceGridId: string;
targetGridId: string | null;
targetPlacement: SectionGridPlacement | null;
state: TransactionalGridState<SectionGridPlacement>;
mode: "drag" | "resize";
valid: boolean;
previewRevision: number;
}
interface ActiveTransaction {
transaction: GridTransaction<SectionGridPlacement>;
label: string;
@@ -173,13 +159,6 @@ const useBoardGridEditorInteraction = () => {
return useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
};
const useBoardGridEditorGridInteraction = (sectionId: string) => {
const store = useBoardGridEditorInteractionStore();
const subscribe = useCallback((listener: () => void) => store.subscribeGrid(sectionId, listener), [sectionId, store]);
const getSnapshot = useCallback(() => store.getGridSnapshot(sectionId), [sectionId, store]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
};
/** One provider coordinates every root and nested grid on the board. */
export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
const board = useRequiredBoard();
@@ -716,10 +695,7 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
return data ? <div className="board-grid-drag-overlay-card">{data.label}</div> : null;
}}
</DragOverlay>
<GridInteractionEffects
cancelActiveInteraction={cancelActiveInteraction}
syncRenderedInteraction={syncRenderedInteraction}
/>
<GridInteractionEffects syncRenderedInteraction={syncRenderedInteraction} />
</DragDropProvider>
</BoardGridEditorInteractionContext.Provider>
</BoardGridEditorActionsContext.Provider>
@@ -727,27 +703,14 @@ export const BoardGridEditorProvider = ({ children }: PropsWithChildren) => {
};
const GridInteractionEffects = ({
cancelActiveInteraction,
syncRenderedInteraction,
}: {
cancelActiveInteraction: () => void;
syncRenderedInteraction: (interaction: GridInteraction | null) => void;
}) => {
const interaction = useBoardGridEditorInteraction();
useLayoutEffect(() => syncRenderedInteraction(interaction), [interaction, syncRenderedInteraction]);
useEffect(() => {
if (interaction?.mode !== "resize") return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
cancelActiveInteraction();
};
window.addEventListener("keydown", handleKeyDown, { capture: true });
return () => window.removeEventListener("keydown", handleKeyDown, { capture: true });
}, [cancelActiveInteraction, interaction?.mode]);
return <GridCollisionSynchronizer previewRevision={interaction?.previewRevision ?? null} />;
};
@@ -775,8 +738,9 @@ export default function GridEditor({
const t = useI18n();
const { section, items, innerSections } = useSectionContext();
const { registerGrid, getDepth, isDropTargetEligible } = useBoardGridEditorActions();
const interactionStore = useBoardGridEditorInteractionStore();
const gridRef = useRef<HTMLDivElement>(null);
const entryElementsRef = useRef(new Map<string, HTMLElement>());
const [entryElements] = useState(() => new Map<string, HTMLElement>());
const parentGridId = "parentSectionId" in section ? section.parentSectionId : null;
const ownerPlacementId = "parentSectionId" in section ? section.id : null;
const depth = getDepth(sectionId);
@@ -811,10 +775,13 @@ 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);
}, []);
const registerEntryElement = useCallback(
(id: string, element: HTMLElement | null) => {
if (element) entryElements.set(id, element);
else entryElements.delete(id);
},
[entryElements],
);
useLayoutEffect(() => {
const element = gridRef.current;
@@ -864,7 +831,8 @@ export default function GridEditor({
maxRowCount={maxRowCount}
placements={placements}
gridRef={gridRef}
entryElementsRef={entryElementsRef}
entryElements={entryElements}
interactionStore={interactionStore}
/>
<span
data-grid-runtime="homarr-dnd-kit-v1"
@@ -876,103 +844,6 @@ 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,
@@ -1023,7 +894,7 @@ const DndGridEntryComponent = ({
const {
ref: draggableRef,
handleRef,
isDragging,
isDragSource,
isDropping,
} = useDraggable<GridEntryData>({
id: placement.id,
@@ -1051,15 +922,7 @@ const DndGridEntryComponent = ({
const handleSelector =
placement.type === "section" ? "[data-grid-container-drag-handle]" : "[data-editor-grid-entry]";
const bindHandle = () => handleRef(container.querySelector<HTMLElement>(handleSelector));
bindHandle();
const observer = new MutationObserver(bindHandle);
observer.observe(container, { childList: true, subtree: true });
return () => {
observer.disconnect();
handleRef(null);
};
return observeGridDragHandle(container, handleSelector, handleRef);
}, [acquireContainer, handleRef, placement.id, placement.type]);
useLayoutEffect(() => {
@@ -1097,7 +960,7 @@ 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-drag-source={isDragging ? "true" : undefined}
data-dnd-drag-source={isDragSource ? "true" : undefined}
data-dnd-dropping={isDropping ? "true" : undefined}
>
<div ref={mountRef} className="board-grid-content-mount" />
@@ -1112,7 +975,7 @@ const DndGridEntryComponent = ({
previewResize={previewResize}
completeResize={completeResize}
cancelResize={cancelResize}
setContinuousResize={resizeOutlineController.schedule}
scheduleResizeOutline={resizeOutlineController.schedule}
/>
</div>
);
@@ -1131,7 +994,7 @@ interface GridResizeHandlesProps {
previewResize: BoardGridEditorActionsContextValue["previewResize"];
completeResize: BoardGridEditorActionsContextValue["completeResize"];
cancelResize: BoardGridEditorActionsContextValue["cancelResize"];
setContinuousResize: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void;
scheduleResizeOutline: (preview: { placement: SectionGridPlacement; valid: boolean } | null) => void;
}
const RESIZE_DIRECTIONS: readonly GridResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
@@ -1147,16 +1010,26 @@ const GridResizeHandles = ({
previewResize,
completeResize,
cancelResize,
setContinuousResize,
scheduleResizeOutline,
}: GridResizeHandlesProps) => {
const pointerStartRef = useRef<{
id: number;
handle: HTMLSpanElement;
x: number;
y: number;
direction: GridResizeDirection;
visualScale: { x: number; y: number };
placement: SectionGridPlacement;
} | null>(null);
const escapeListenerRef = useRef<((event: KeyboardEvent) => void) | null>(null);
useEffect(
() => () => {
const listener = escapeListenerRef.current;
if (listener) window.removeEventListener("keydown", listener, { capture: true });
},
[],
);
const start = (direction: GridResizeDirection) =>
beginResize({ activeId: placement.id, sourceGridId: sectionId, label }) && direction;
@@ -1170,6 +1043,44 @@ const GridResizeHandles = ({
minHeight: placement.minH ?? 1,
});
const removeEscapeListener = () => {
const listener = escapeListenerRef.current;
if (!listener) return;
window.removeEventListener("keydown", listener, { capture: true });
escapeListenerRef.current = null;
};
const finishResize = (pointerId: number, current: { x: number; y: number } | null, canceled: boolean) => {
const started = pointerStartRef.current;
if (!started || started.id !== pointerId) return;
pointerStartRef.current = null;
scheduleResizeOutline(null);
removeEscapeListener();
if (started.handle.hasPointerCapture(pointerId)) started.handle.releasePointerCapture(pointerId);
if (canceled || !current) {
cancelResize();
return;
}
const delta = getSnappedGridDelta({
initial: { x: started.x, y: started.y },
current,
visualScale: started.visualScale,
});
if (delta) update(started.direction, delta.x, delta.y);
completeResize();
};
const handleEscape = (event: KeyboardEvent) => {
const started = pointerStartRef.current;
if (event.key !== "Escape" || !started) return;
event.preventDefault();
event.stopPropagation();
finishResize(started.id, null, true);
};
const handlePointerDown = (event: ReactPointerEvent<HTMLSpanElement>, direction: GridResizeDirection) => {
const visualScale = getEntryVisualScale(event.currentTarget.parentElement, placement);
if (
@@ -1191,12 +1102,15 @@ const GridResizeHandles = ({
}
pointerStartRef.current = {
id: event.pointerId,
handle: event.currentTarget,
x: event.clientX,
y: event.clientY,
direction,
visualScale,
placement: { ...placement },
};
escapeListenerRef.current = handleEscape;
window.addEventListener("keydown", handleEscape, { capture: true });
};
const handlePointerMove = (event: ReactPointerEvent<HTMLSpanElement>) => {
@@ -1212,7 +1126,7 @@ const GridResizeHandles = ({
const continuousDelta = getContinuousGridDelta(input);
if (!snappedDelta || !continuousDelta) return;
const valid = update(started.direction, snappedDelta.x, snappedDelta.y);
setContinuousResize({
scheduleResizeOutline({
placement: getContinuousResizePlacement({
placement: started.placement,
direction: started.direction,
@@ -1228,22 +1142,7 @@ const GridResizeHandles = ({
};
const handlePointerEnd = (event: ReactPointerEvent<HTMLSpanElement>, canceled: boolean) => {
const started = pointerStartRef.current;
if (!started || started.id !== event.pointerId) return;
pointerStartRef.current = null;
setContinuousResize(null);
if (event.currentTarget.hasPointerCapture(event.pointerId))
event.currentTarget.releasePointerCapture(event.pointerId);
if (canceled) cancelResize();
else {
const delta = getSnappedGridDelta({
initial: { x: started.x, y: started.y },
current: { x: event.clientX, y: event.clientY },
visualScale: started.visualScale,
});
if (delta) update(started.direction, delta.x, delta.y);
completeResize();
}
finishResize(event.pointerId, { x: event.clientX, y: event.clientY }, canceled);
};
return RESIZE_DIRECTIONS.map((direction) => (
@@ -1257,12 +1156,7 @@ const GridResizeHandles = ({
onPointerMove={handlePointerMove}
onPointerUp={(event) => handlePointerEnd(event, false)}
onPointerCancel={(event) => handlePointerEnd(event, true)}
onLostPointerCapture={(event) => {
if (pointerStartRef.current?.id !== event.pointerId) return;
pointerStartRef.current = null;
setContinuousResize(null);
cancelResize();
}}
onLostPointerCapture={(event) => finishResize(event.pointerId, null, true)}
/>
));
};
@@ -1325,11 +1219,6 @@ 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,10 +6,10 @@ export interface GridPreviewDomState {
changedIds: Set<string>;
}
interface SyncGridPreviewGeometryInput<TPlacement extends GridPlacement> {
interface SyncGridPreviewGeometryInput {
elements: ReadonlyMap<string, HTMLElement>;
placements: readonly TPlacement[];
previewPlacements: readonly TPlacement[] | null;
placements: readonly GridPlacement[];
previewPlacements: readonly GridPlacement[] | null;
activeId: string | null;
mode: "drag" | "resize" | null;
previous: GridPreviewDomState;
@@ -20,14 +20,14 @@ export const createGridPreviewDomState = (): GridPreviewDomState => ({
changedIds: new Set(),
});
export const syncGridPreviewGeometry = <TPlacement extends GridPlacement>({
export const syncGridPreviewGeometry = ({
elements,
placements,
previewPlacements,
activeId,
mode,
previous,
}: SyncGridPreviewGeometryInput<TPlacement>): GridPreviewDomState => {
}: SyncGridPreviewGeometryInput): GridPreviewDomState => {
const controlledById = new Map(placements.map((placement) => [placement.id, placement]));
const changedIds = new Set<string>();
@@ -58,9 +58,9 @@ export const syncGridPreviewGeometry = <TPlacement extends GridPlacement>({
return { activeId, changedIds };
};
export const clearGridPreviewGeometry = <TPlacement extends GridPlacement>(
export const clearGridPreviewGeometry = (
elements: ReadonlyMap<string, HTMLElement>,
placements: readonly TPlacement[],
placements: readonly GridPlacement[],
state: GridPreviewDomState,
) => {
const controlledById = new Map(placements.map((placement) => [placement.id, placement]));
@@ -0,0 +1,136 @@
"use client";
import type { RefObject } from "react";
import { useCallback, useLayoutEffect, useRef, useSyncExternalStore } from "react";
import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize } from "~/components/board/layout";
import type { TransactionalGridState } from "./dnd";
import type { GridInteractionStore } from "./grid-interaction-store";
import { clearGridPreviewGeometry, createGridPreviewDomState, syncGridPreviewGeometry } from "./grid-preview-geometry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
export interface GridInteraction {
activeId: string;
sourceGridId: string;
targetGridId: string | null;
targetPlacement: SectionGridPlacement | null;
state: TransactionalGridState<SectionGridPlacement>;
mode: "drag" | "resize";
valid: boolean;
previewRevision: number;
}
interface GridPreviewLayerProps {
sectionId: string;
rowCount: number;
maxRowCount: number | null;
placements: readonly SectionGridPlacement[];
gridRef: RefObject<HTMLDivElement | null>;
entryElements: ReadonlyMap<string, HTMLElement>;
interactionStore: GridInteractionStore<GridInteraction>;
}
export const GridPreviewLayer = ({
sectionId,
rowCount,
maxRowCount,
placements,
gridRef,
entryElements,
interactionStore,
}: GridPreviewLayerProps) => {
const subscribe = useCallback(
(listener: () => void) => interactionStore.subscribeGrid(sectionId, listener),
[interactionStore, sectionId],
);
const getSnapshot = useCallback(() => interactionStore.getGridSnapshot(sectionId), [interactionStore, sectionId]);
const interaction = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const previewGrid = interaction?.state.grids.find((grid) => grid.id === sectionId);
const previewStateRef = useRef(createGridPreviewDomState());
const placementsRef = useRef(placements);
const isDragging = interaction?.mode === "drag";
const isTargetGrid = isDragging && interaction.targetGridId === sectionId;
let targetPlacement: SectionGridPlacement | null = null;
if (interaction?.valid && interaction.targetGridId === sectionId) {
targetPlacement = interaction.targetPlacement;
}
const growsDuringDrag =
isDragging && (isTargetGrid || (interaction.targetGridId === null && interaction.sourceGridId === sectionId));
const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? placements);
let renderedRowCount = rowCount;
if (maxRowCount === null && interaction) {
renderedRowCount = Math.max(rowCount, previewRowCount);
if (growsDuringDrag) renderedRowCount += 1;
}
useLayoutEffect(() => {
placementsRef.current = placements;
previewStateRef.current = syncGridPreviewGeometry({
elements: entryElements,
placements,
previewPlacements: previewGrid?.placements ?? null,
activeId: interaction?.activeId ?? null,
mode: interaction?.mode ?? null,
previous: previewStateRef.current,
});
const grid = gridRef.current;
if (!grid) return;
grid.dataset.dndDropTarget = String(isTargetGrid);
if (isTargetGrid) grid.dataset.dndDropValid = String(targetPlacement !== null);
else delete grid.dataset.dndDropValid;
if (interaction?.targetGridId === sectionId) {
grid.dataset.dndPreviewRevision = String(interaction.previewRevision);
} else {
delete grid.dataset.dndPreviewRevision;
}
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`);
}
}, [
entryElements,
gridRef,
interaction,
isTargetGrid,
placements,
previewGrid?.placements,
renderedRowCount,
sectionId,
targetPlacement,
]);
useLayoutEffect(
() => () => {
clearGridPreviewGeometry(entryElements, 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");
},
[entryElements, gridRef],
);
if (!targetPlacement) return null;
return (
<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"
/>
);
};
@@ -1,17 +1,17 @@
import { getLogicalTrackSize, LOGICAL_GRID_PITCH } from "~/components/board/layout";
import type { GridPlacement } from "~/components/board/layout";
interface ResizeOutlinePreview<TPlacement extends GridPlacement> {
placement: TPlacement;
interface ResizeOutlinePreview {
placement: GridPlacement;
valid: boolean;
}
export const createGridResizeOutlineController = <TPlacement extends GridPlacement>(
export const createGridResizeOutlineController = (
getShell: () => HTMLElement | null,
getControlledPlacement: () => TPlacement,
getControlledPlacement: () => GridPlacement,
) => {
let frame = 0;
let pending: ResizeOutlinePreview<TPlacement> | null = null;
let pending: ResizeOutlinePreview | null = null;
const remove = () => {
getShell()?.querySelector(":scope > [data-grid-resize-outline]")?.remove();
@@ -37,7 +37,7 @@ export const createGridResizeOutlineController = <TPlacement extends GridPlaceme
};
return {
schedule(preview: ResizeOutlinePreview<TPlacement> | null) {
schedule(preview: ResizeOutlinePreview | null) {
pending = preview;
if (!preview) {
window.cancelAnimationFrame(frame);
@@ -0,0 +1,33 @@
import { describe, expect, test, vi } from "vitest";
import { observeGridDragHandle } from "../grid-drag-handle";
describe("grid drag handle", () => {
test("disconnects the observer after binding the first handle", async () => {
const container = document.createElement("div");
document.body.append(container);
const bind = vi.fn();
const cleanup = observeGridDragHandle(container, "[data-drag-handle]", bind);
expect(bind).not.toHaveBeenCalled();
container.append(document.createElement("span"));
await Promise.resolve();
expect(bind).not.toHaveBeenCalled();
const handle = document.createElement("button");
handle.dataset.dragHandle = "";
container.append(handle);
await vi.waitFor(() => expect(bind).toHaveBeenCalledOnce());
expect(bind).toHaveBeenLastCalledWith(handle);
handle.append(document.createElement("span"));
container.prepend(document.createElement("button"));
await Promise.resolve();
expect(bind).toHaveBeenCalledOnce();
cleanup();
expect(bind).toHaveBeenLastCalledWith(null);
container.remove();
});
});
@@ -5,7 +5,9 @@ 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";
import { createGridInteractionStore } from "../grid-interaction-store";
import type { GridInteraction } from "../grid-preview-layer";
import { GridPreviewLayer } from "../grid-preview-layer";
describe("grid entry memoization", () => {
let container: HTMLDivElement;
@@ -76,8 +78,13 @@ describe("grid entry memoization", () => {
expect(renders).toBe(101);
});
test("keeps 100 entry and widget renders frozen while preview geometry changes", () => {
test("keeps 100 entry and widget renders frozen across interaction store previews", () => {
const elements = new Map<string, HTMLElement>();
const interactionStore = createGridInteractionStore<GridInteraction>();
const gridRef = { current: null as HTMLDivElement | null };
const setGridRef = (element: HTMLDivElement | null) => {
gridRef.current = element;
};
const registerElement = (id: string, element: HTMLElement | null) => {
if (element) elements.set(id, element);
else elements.delete(id);
@@ -87,21 +94,50 @@ describe("grid entry memoization", () => {
registerElement,
placement: { ...createProps().placement, id: `item-${index}`, x: index % 10, y: Math.floor(index / 10) },
}));
renderBoard(entries);
const renderPreviewBoard = (nextEntries: DndGridEntryProps[]) => {
act(() =>
root.render(
createElement(
"div",
{ "data-section-id": "root" },
createElement(
"div",
{ ref: setGridRef },
nextEntries.map((entry) => createElement(Probe, { ...entry, key: entry.placement.id })),
createElement(GridPreviewLayer, {
sectionId: "root",
rowCount: 10,
maxRowCount: null,
placements: nextEntries.map((entry) => entry.placement),
gridRef,
entryElements: elements,
interactionStore,
}),
),
),
),
);
};
renderPreviewBoard(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,
const previewPlacements = entries.map((entry, index) =>
index === 42 ? { ...entry.placement, y: entry.placement.y + revision } : entry.placement,
);
act(() => {
interactionStore.publish({
activeId: "item-0",
sourceGridId: "root",
targetGridId: "root",
targetPlacement: previewPlacements[0] ?? null,
state: { grids: [{ id: "root", columnCount: 12, maxRowCount: null, placements: previewPlacements }] },
mode: "drag",
valid: true,
previewRevision: revision,
});
});
}
@@ -109,13 +145,16 @@ describe("grid entry memoization", () => {
expect(widgetRenders).toBe(100);
expect(elements.get("item-42")?.getAttribute("data-grid-preview")).toBe("true");
renderBoard(
act(() => interactionStore.publish(null));
renderPreviewBoard(
entries.map((entry, index) =>
index === 42 ? { ...entry, placement: { ...entry.placement, y: entry.placement.y + 20 } } : entry,
),
);
expect(renders).toBe(101);
expect(widgetRenders).toBe(101);
expect(gridRef.current?.dataset.dndDropTarget).toBe("false");
expect(gridRef.current?.hasAttribute("data-dnd-preview-revision")).toBe(false);
});
test.each([
+2
View File
@@ -843,6 +843,8 @@ describe("Board grid", () => {
}),
).toBe(true);
await page.keyboard.press("Escape");
await expect(resizeOutline).toHaveCount(0);
await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting");
await page.mouse.up();
await expect(containerSection).toHaveAttribute("data-grid-w", "2");