fix: improve board grid interactions
This commit is contained in:
1 parent
b7693c4238
commit
bf91f92d56
11 files changed
+349
-75
No files matched your search
@@ -219,8 +219,9 @@ Press and hold the handle, move the pointer, and release it when the tile has th
|
||||
|
||||

|
||||
|
||||
To move a tile in edit mode, press and hold anywhere on the tile except one of its buttons or controls, move it to the desired position, and release it.
|
||||
The tile snaps to the grid and neighboring tiles move out of the way automatically.
|
||||
To move a tile in edit mode, press and hold its content, move it to the desired position, and release it.
|
||||
Widget content is disabled while editing so its controls do not intercept the drag; use the tile's settings button for edit actions.
|
||||
The tile snaps to the grid. Equally-sized tiles swap when one is dropped into the other's slot, while other neighboring tiles move out of the way automatically.
|
||||
|
||||

|
||||
|
||||
|
||||
@@ -10,3 +10,7 @@
|
||||
background-color: var(--background-color) !important;
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
.editModeInertContent {
|
||||
display: contents;
|
||||
}
|
||||
@@ -1,24 +1,29 @@
|
||||
import type { MutableRefObject } from "react";
|
||||
import { useRef } from "react";
|
||||
import type { ComponentType, MutableRefObject } from "react";
|
||||
import { Suspense, use, useRef } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { Badge, Card } from "@mantine/core";
|
||||
import type { CardProps } from "@mantine/core";
|
||||
import { Badge, Card, Center, Loader } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
|
||||
import combineClasses from "clsx";
|
||||
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
|
||||
import type { FallbackProps } from "react-error-boundary";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { useRequiredBoard } from "@homarr/boards/context";
|
||||
import { useEditMode } from "@homarr/boards/edit-mode";
|
||||
import { useSettings } from "@homarr/settings";
|
||||
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
|
||||
import { WidgetError } from "@homarr/widgets/errors";
|
||||
import { getWidgetQueryKeys } from "@homarr/widgets/definition";
|
||||
import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition";
|
||||
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
|
||||
|
||||
import type { SectionItem } from "~/app/[locale]/boards/_types";
|
||||
import classes from "../sections/item.module.css";
|
||||
import { useItemActions } from "./item-actions";
|
||||
import itemContentClasses from "./item-content.module.css";
|
||||
import { WidgetContextMenu } from "./widget-context-menu";
|
||||
import { removeWidgetDataQueries } from "./widget-query-recovery";
|
||||
|
||||
const BoardItemMenu = dynamic(() => import("./item-menu").then((module) => module.BoardItemMenu), {
|
||||
ssr: false,
|
||||
@@ -28,12 +33,63 @@ interface BoardItemContentProps {
|
||||
item: SectionItem;
|
||||
}
|
||||
|
||||
const getOverflowFromKind = (kind: SectionItem["kind"]) => {
|
||||
if (kind === "iframe") return "hidden";
|
||||
if (kind === "systemResources") return "visible";
|
||||
return undefined;
|
||||
const getOverflowFromKind = (kind: SectionItem["kind"], hasCustomCssClasses: boolean) => {
|
||||
if (kind === "systemResources") return { overflowX: "visible", overflowY: "visible" } as const;
|
||||
if (kind === "iframe") return { overflowX: "hidden", overflowY: "hidden" } as const;
|
||||
if (hasCustomCssClasses) return {};
|
||||
return { overflowX: "hidden", overflowY: "auto" } as const;
|
||||
};
|
||||
|
||||
type BoardItemCardProps = CardProps & {
|
||||
item: SectionItem;
|
||||
innerRef: (element: HTMLDivElement | null) => void;
|
||||
};
|
||||
|
||||
const BoardItemCard = ({ item, innerRef, children, ...cardProps }: BoardItemCardProps) => {
|
||||
const board = useRequiredBoard();
|
||||
|
||||
return (
|
||||
<Card
|
||||
{...cardProps}
|
||||
ref={innerRef}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-grid-item-content
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"board-grid-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const WidgetDefinitionLoadError = ({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
item,
|
||||
innerRef,
|
||||
}: FallbackProps & Pick<BoardItemCardProps, "item" | "innerRef">) => (
|
||||
<BoardItemCard item={item} innerRef={innerRef}>
|
||||
<Center h="100%">
|
||||
<WidgetError error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</Center>
|
||||
</BoardItemCard>
|
||||
);
|
||||
|
||||
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
const board = useRequiredBoard();
|
||||
@@ -41,32 +97,28 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
|
||||
<Card
|
||||
ref={ref}
|
||||
w="100%"
|
||||
h="100%"
|
||||
data-grid-item-content
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
`${item.kind}-wrapper`,
|
||||
"grid-stack-item-content",
|
||||
item.advancedOptions.customCssClasses.join(" "),
|
||||
)}
|
||||
radius={board.itemRadius}
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
containerType: "size",
|
||||
overflow: getOverflowFromKind(item.kind),
|
||||
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<BoardItemCard item={item} innerRef={ref}>
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
</BoardItemCard>
|
||||
}
|
||||
>
|
||||
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
|
||||
</Card>
|
||||
</WidgetContextMenu>
|
||||
<LoadedBoardItemContent
|
||||
item={item}
|
||||
width={width}
|
||||
height={height}
|
||||
widgetStateRef={widgetStateRef}
|
||||
innerRef={ref}
|
||||
/>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
{item.advancedOptions.title?.trim() && (
|
||||
<Badge
|
||||
pos="absolute"
|
||||
@@ -92,40 +144,82 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definition" | "Component"> {
|
||||
innerRef: (element: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
|
||||
const { definition, Component } = use(loadWidgetResources(item.kind));
|
||||
const [isEditMode] = useEditMode();
|
||||
|
||||
return (
|
||||
<WidgetContextMenu item={item} definition={definition} widgetStateRef={innerContentProps.widgetStateRef}>
|
||||
<BoardItemCard item={item} innerRef={innerRef}>
|
||||
<ErrorBoundary
|
||||
resetKeys={[item.kind]}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
<BoardItemMenu item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<InnerContent item={item} definition={definition} Component={Component} {...innerContentProps} />
|
||||
</ErrorBoundary>
|
||||
</BoardItemCard>
|
||||
</WidgetContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
interface InnerContentProps {
|
||||
item: SectionItem;
|
||||
width: number;
|
||||
height: number;
|
||||
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
|
||||
definition: WidgetDefinition;
|
||||
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
|
||||
}
|
||||
|
||||
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
|
||||
const settings = useSettings();
|
||||
const board = useRequiredBoard();
|
||||
const [isEditMode] = useEditMode();
|
||||
const Comp = loadWidgetDynamic(item.kind);
|
||||
const { definition } = widgetImports[item.kind];
|
||||
const options = reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options);
|
||||
const options = reduceWidgetOptionsWithDefinition(definition, settings, item.options);
|
||||
const newItem = { ...item, options };
|
||||
const { removeItem, updateItemOptions } = useItemActions();
|
||||
const updateOptions = ({ newOptions }: { newOptions: Record<string, unknown> }) =>
|
||||
updateItemOptions({ itemId: item.id, newOptions });
|
||||
const widgetSupportsIntegrations =
|
||||
"supportedIntegrations" in definition && definition.supportedIntegrations.length >= 1;
|
||||
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) >= 1;
|
||||
const queryClient = useQueryClient();
|
||||
const widgetQueryKeys = getWidgetQueryKeys(definition, item.kind);
|
||||
|
||||
return (
|
||||
<QueryErrorResetBoundary>
|
||||
{({ reset }) => (
|
||||
<ErrorBoundary
|
||||
onReset={() => {
|
||||
void queryClient.invalidateQueries({ queryKey: [["widget"]] });
|
||||
removeWidgetDataQueries(queryClient, widgetQueryKeys);
|
||||
reset();
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary, error }) => (
|
||||
<>
|
||||
{isEditMode && <BoardItemMenu offset={4} item={newItem} resetErrorBoundary={resetErrorBoundary} />}
|
||||
<WidgetError kind={item.kind} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
{isEditMode && (
|
||||
<BoardItemMenu item={newItem} definition={definition} resetErrorBoundary={resetErrorBoundary} />
|
||||
)}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
@@ -137,24 +231,31 @@ const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
|
||||
(!("integrationsRequired" in definition) || definition.integrationsRequired !== false)
|
||||
}
|
||||
/>
|
||||
{isEditMode && <BoardItemMenu offset={4} item={newItem} />}
|
||||
<Comp
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
removeItem={() => removeItem({ itemId: item.id })}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
{isEditMode && <BoardItemMenu item={newItem} definition={definition} />}
|
||||
<div
|
||||
className={itemContentClasses.editModeInertContent}
|
||||
data-board-grid-inert-content={isEditMode ? "true" : undefined}
|
||||
data-homarr-widget-ready={item.kind}
|
||||
inert={isEditMode}
|
||||
>
|
||||
<Component
|
||||
options={options as never}
|
||||
integrationIds={item.integrationIds}
|
||||
isEditMode={isEditMode}
|
||||
boardId={board.id}
|
||||
itemId={item.id}
|
||||
removeItem={() => removeItem({ itemId: item.id })}
|
||||
setOptions={(partialNewOptions) =>
|
||||
updateOptions({
|
||||
newOptions: {
|
||||
...options,
|
||||
...partialNewOptions.newOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
{...dimensions}
|
||||
/>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</QueryErrorResetBoundary>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
|
||||
/**
|
||||
* GridStack's floating mode lets Homarr keep intentional empty rows, but it
|
||||
* disables GridStack's built-in swap path. Restore the intuitive subset: when
|
||||
* an item lands exactly in an equally-sized item's former slot, exchange the
|
||||
* two slots and leave every other item where it started.
|
||||
*/
|
||||
export const getDropSwapPlacements = (
|
||||
initial: readonly SectionGridPlacement[],
|
||||
current: readonly SectionGridPlacement[],
|
||||
movedItemId: string,
|
||||
): SectionGridPlacement[] | null => {
|
||||
const movedInitially = initial.find((placement) => placement.id === movedItemId);
|
||||
const movedCurrently = current.find((placement) => placement.id === movedItemId);
|
||||
if (!movedInitially || !movedCurrently) return null;
|
||||
if (
|
||||
movedInitially.x === movedCurrently.x &&
|
||||
movedInitially.y === movedCurrently.y &&
|
||||
movedInitially.w === movedCurrently.w &&
|
||||
movedInitially.h === movedCurrently.h
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displaced = initial.find(
|
||||
(placement) =>
|
||||
placement.id !== movedItemId &&
|
||||
placement.x === movedCurrently.x &&
|
||||
placement.y === movedCurrently.y &&
|
||||
placement.w === movedCurrently.w &&
|
||||
placement.h === movedCurrently.h,
|
||||
);
|
||||
if (!displaced) return null;
|
||||
|
||||
return initial.map((placement) => {
|
||||
if (placement.id === movedItemId) {
|
||||
return {
|
||||
...placement,
|
||||
x: displaced.x,
|
||||
y: displaced.y,
|
||||
};
|
||||
}
|
||||
if (placement.id === displaced.id) {
|
||||
return {
|
||||
...placement,
|
||||
x: movedInitially.x,
|
||||
y: movedInitially.y,
|
||||
};
|
||||
}
|
||||
return placement;
|
||||
});
|
||||
};
|
||||
@@ -23,9 +23,10 @@
|
||||
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-se,
|
||||
.grid-stack.grid-stack-editing > .grid-stack-item > .ui-resizable-sw {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background-size: 12px 12px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
background-size: 16px 16px;
|
||||
z-index: 9;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { getLogicalTrackSize, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
import { useSectionContext } from "../section-context";
|
||||
import { getDropSwapPlacements } from "./grid-drop-swap";
|
||||
import { useBoardGridPortalHost } from "./grid-portal-host";
|
||||
import { decorateGridResizeHandles } from "./grid-resize-handles";
|
||||
import type { SectionGridPlacement } from "./use-grid-layout-actions";
|
||||
@@ -54,6 +55,11 @@ export default function GridEditor({
|
||||
const gridRef = useRef<GridStack | null>(null);
|
||||
const applyingControlledLayoutRef = useRef(false);
|
||||
const placementsRef = useRef(placements);
|
||||
const dragSnapshotRef = useRef<{
|
||||
grid: GridStack;
|
||||
itemId: string;
|
||||
placements: SectionGridPlacement[];
|
||||
} | null>(null);
|
||||
const entriesRef = useRef([...items, ...innerSections]);
|
||||
const acceptWidgetRef = useRef<(element: Element) => boolean>(() => false);
|
||||
const dimensionsRef = useRef({ columnCount, fixedRowCount, rowCount });
|
||||
@@ -205,12 +211,43 @@ export default function GridEditor({
|
||||
wrapper.classList.add("grid-stack-editing");
|
||||
|
||||
const handleStop = (_event: Event, element: GridItemHTMLElement) => {
|
||||
eventHandlersRef.current.commitGrid(element.gridstackNode?.grid ?? grid);
|
||||
eventHandlersRef.current.announceNode(element.gridstackNode);
|
||||
decorateGrid(element.gridstackNode?.grid ?? grid, placementsRef.current);
|
||||
restoreContentHeight(grid);
|
||||
const changedGrid = element.gridstackNode?.grid ?? grid;
|
||||
const dragSnapshot = dragSnapshotRef.current;
|
||||
dragSnapshotRef.current = null;
|
||||
queueMicrotask(() => {
|
||||
if (dragSnapshot?.grid === changedGrid) {
|
||||
const swapped = getDropSwapPlacements(
|
||||
dragSnapshot.placements,
|
||||
getGridPlacements(changedGrid),
|
||||
dragSnapshot.itemId,
|
||||
);
|
||||
if (swapped) {
|
||||
applyingControlledLayoutRef.current = true;
|
||||
try {
|
||||
changedGrid.load(swapped.map(toGridStackWidget), shellCallback);
|
||||
} finally {
|
||||
applyingControlledLayoutRef.current = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventHandlersRef.current.commitGrid(changedGrid);
|
||||
eventHandlersRef.current.announceNode(element.gridstackNode);
|
||||
decorateGrid(changedGrid, placementsRef.current);
|
||||
restoreContentHeight(grid);
|
||||
});
|
||||
};
|
||||
const handleDragStart = () => {
|
||||
const handleDragStart = (_event: Event, element: GridItemHTMLElement) => {
|
||||
const changedGrid = element.gridstackNode?.grid ?? grid;
|
||||
const itemId = element.getAttribute("data-grid-item-id") ?? element.gridstackNode?.id;
|
||||
if (itemId) {
|
||||
dragSnapshotRef.current = {
|
||||
grid: changedGrid,
|
||||
itemId: String(itemId),
|
||||
placements: getGridPlacements(changedGrid),
|
||||
};
|
||||
}
|
||||
|
||||
const currentDimensions = dimensionsRef.current;
|
||||
if (currentDimensions.fixedRowCount) return;
|
||||
|
||||
@@ -224,6 +261,7 @@ export default function GridEditor({
|
||||
previousNode: GridStackNode | undefined,
|
||||
newNode: GridStackNode | undefined,
|
||||
) => {
|
||||
dragSnapshotRef.current = null;
|
||||
queueMicrotask(() => {
|
||||
if (applyingControlledLayoutRef.current) return;
|
||||
const snapshots = uniqueGrids([previousNode?.grid, newNode?.grid ?? grid])
|
||||
@@ -358,7 +396,7 @@ const decorateItem = (element: GridItemHTMLElement, type: SectionGridPlacement["
|
||||
element.setAttribute("data-grid-y", String(node.y ?? 0));
|
||||
element.setAttribute("data-grid-w", String(node.w ?? 1));
|
||||
element.setAttribute("data-grid-h", String(node.h ?? 1));
|
||||
decorateGridResizeHandles(element);
|
||||
decorateGridResizeHandles(element, { minW: node.minW, minH: node.minH });
|
||||
};
|
||||
|
||||
const getElementItemType = (element: Element): SectionGridPlacement["type"] | undefined => {
|
||||
|
||||
@@ -129,7 +129,10 @@ const GridPortalContent = ({ itemId, ownerSectionId, container, integrations, an
|
||||
if (!itemElement) return;
|
||||
|
||||
itemElement.gridstackNode?.grid?.refreshDragHandles(itemElement);
|
||||
decorateGridResizeHandles(itemElement);
|
||||
decorateGridResizeHandles(itemElement, {
|
||||
minW: itemElement.gridstackNode?.minW,
|
||||
minH: itemElement.gridstackNode?.minH,
|
||||
});
|
||||
}, [container, ownerSectionId]);
|
||||
|
||||
if (!entry || !section || !currentLayout) return null;
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
export const decorateGridResizeHandles = (element: Element) => {
|
||||
import { LOGICAL_GRID_PITCH } from "~/components/board/layout";
|
||||
|
||||
interface GridResizeMinimum {
|
||||
minW?: number;
|
||||
minH?: number;
|
||||
}
|
||||
|
||||
export const decorateGridResizeHandles = (element: HTMLElement, minimum: GridResizeMinimum = {}) => {
|
||||
element.style.minWidth = `${(minimum.minW ?? 1) * LOGICAL_GRID_PITCH}px`;
|
||||
element.style.minHeight = `${(minimum.minH ?? 1) * LOGICAL_GRID_PITCH}px`;
|
||||
|
||||
for (const handle of element.querySelectorAll<HTMLElement>(".ui-resizable-handle")) {
|
||||
const direction = Array.from(handle.classList).find((className) =>
|
||||
/^ui-resizable-(?:n|ne|e|se|s|sw|w|nw)$/.test(className),
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { getDropSwapPlacements } from "../grid-drop-swap";
|
||||
import type { SectionGridPlacement } from "../use-grid-layout-actions";
|
||||
|
||||
const first: SectionGridPlacement = { id: "first", type: "item", x: 0, y: 0, w: 1, h: 1 };
|
||||
const second: SectionGridPlacement = { id: "second", type: "item", x: 1, y: 0, w: 1, h: 1 };
|
||||
const third: SectionGridPlacement = { id: "third", type: "item", x: 2, y: 0, w: 2, h: 1 };
|
||||
const initial = [first, second, third];
|
||||
|
||||
describe("grid drop swapping", () => {
|
||||
test("swaps equally-sized items when one takes the other's exact slot", () => {
|
||||
const current: SectionGridPlacement[] = [{ ...first, x: 1 }, { ...second, y: 1 }, third];
|
||||
|
||||
expect(getDropSwapPlacements(initial, current, "first")).toEqual([{ ...first, x: 1 }, { ...second, x: 0 }, third]);
|
||||
});
|
||||
|
||||
test("keeps push behavior for different sizes or partial placement", () => {
|
||||
const differentSize: SectionGridPlacement[] = [{ ...first, x: 2 }, second, { ...third, y: 1 }];
|
||||
const partialPlacement: SectionGridPlacement[] = [{ ...third, x: 1 }, first, { ...second, y: 1 }];
|
||||
|
||||
expect(getDropSwapPlacements(initial, differentSize, "first")).toBeNull();
|
||||
expect(getDropSwapPlacements(initial, partialPlacement, "third")).toBeNull();
|
||||
});
|
||||
|
||||
test("does nothing when the item did not move", () => {
|
||||
expect(getDropSwapPlacements(initial, initial, "first")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { LOGICAL_GRID_PITCH } from "../../../layout";
|
||||
import { decorateGridResizeHandles } from "../grid-resize-handles";
|
||||
|
||||
describe("grid resize handle decoration", () => {
|
||||
@@ -10,7 +11,7 @@ describe("grid resize handle decoration", () => {
|
||||
<div class="ui-resizable-handle ui-resizable-se"></div>
|
||||
`;
|
||||
|
||||
decorateGridResizeHandles(item);
|
||||
decorateGridResizeHandles(item, { minW: 2, minH: 3 });
|
||||
|
||||
const east = item.querySelector<HTMLElement>(".ui-resizable-e");
|
||||
const southeast = item.querySelector<HTMLElement>(".ui-resizable-se");
|
||||
@@ -20,5 +21,7 @@ describe("grid resize handle decoration", () => {
|
||||
expect(southeast?.dataset.testid).toBe("board-grid-resize-handle");
|
||||
expect(southeast?.dataset.gridResizeDirection).toBe("se");
|
||||
expect(southeast?.getAttribute("aria-hidden")).toBe("true");
|
||||
expect(item.style.minWidth).toBe(`${2 * LOGICAL_GRID_PITCH}px`);
|
||||
expect(item.style.minHeight).toBe(`${3 * LOGICAL_GRID_PITCH}px`);
|
||||
});
|
||||
});
|
||||
+33
-2
@@ -208,6 +208,8 @@ describe("Board grid", () => {
|
||||
await expect(firstItem.locator(`[data-grid-id="${fixture.firstItemId}"]`)).toHaveAttribute("role", "group");
|
||||
await expectTargetSizeAsync(getEditorEntry(firstItem));
|
||||
await expectTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]'));
|
||||
await expectLogicalTargetSizeAsync(firstItem.locator(':scope > [data-testid="board-grid-resize-handle"]'), 44);
|
||||
await expect(firstItem.locator("[data-board-grid-inert-content]")).toHaveAttribute("inert", "");
|
||||
|
||||
await page.getByRole("button", { name: "Add board content" }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "New section", exact: true })).toBeVisible();
|
||||
@@ -220,7 +222,11 @@ describe("Board grid", () => {
|
||||
|
||||
const railBoxBeforeMainDrag = await expectBoundingBoxAsync(rail);
|
||||
const canvasScale = await readCanvasScaleAsync(canvas);
|
||||
await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale * 4, 0);
|
||||
await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale, 0);
|
||||
await expect(firstItem).toHaveAttribute("data-grid-x", "1");
|
||||
await expect(secondItem).toHaveAttribute("data-grid-x", "0");
|
||||
|
||||
await dragLocatorByAsync(page, getEditorEntry(firstItem), logicalCellPitch * canvasScale * 3, 0);
|
||||
await expect(firstItem).toHaveAttribute("data-grid-x", "4");
|
||||
expect(gridLayoutsOverlap(await readGridLayoutAsync(firstItem), await readGridLayoutAsync(secondItem))).toBe(
|
||||
false,
|
||||
@@ -279,7 +285,23 @@ describe("Board grid", () => {
|
||||
await expect(railKeyboardEntry).toHaveAttribute("data-keyboard-editing", "false");
|
||||
|
||||
const dynamicResizeHandle = dynamicSection.locator(':scope > [data-testid="board-grid-resize-handle"]');
|
||||
await dragLocatorByAsync(page, dynamicResizeHandle, 0, logicalCellPitch * canvasScale);
|
||||
await expectLogicalTargetSizeAsync(dynamicResizeHandle, 44);
|
||||
await dragLocatorByAsync(page, dynamicResizeHandle, 0, -logicalCellPitch * canvasScale);
|
||||
await expect(dynamicSection).toHaveAttribute("data-grid-h", "1");
|
||||
|
||||
const minimumSectionBox = await expectBoundingBoxAsync(dynamicSection);
|
||||
const minimumHandleBox = await expectBoundingBoxAsync(dynamicResizeHandle);
|
||||
const resizeStartX = minimumHandleBox.x + minimumHandleBox.width / 2;
|
||||
const resizeStartY = minimumHandleBox.y + minimumHandleBox.height / 2;
|
||||
await page.mouse.move(resizeStartX, resizeStartY);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(resizeStartX, resizeStartY - logicalCellPitch * canvasScale * 2, { steps: 12 });
|
||||
const constrainedSectionBox = await expectBoundingBoxAsync(dynamicSection);
|
||||
expect(Math.abs(constrainedSectionBox.height - minimumSectionBox.height)).toBeLessThan(1);
|
||||
await page.mouse.up();
|
||||
await expect(dynamicSection).toHaveAttribute("data-grid-h", "1");
|
||||
|
||||
await dragLocatorByAsync(page, dynamicResizeHandle, 0, logicalCellPitch * canvasScale * 2);
|
||||
await expect(dynamicSection).toHaveAttribute("data-grid-h", "3");
|
||||
await expect(belowItem).toHaveAttribute("data-grid-y", "3");
|
||||
expect(gridLayoutsOverlap(await readGridLayoutAsync(dynamicSection), await readGridLayoutAsync(belowItem))).toBe(
|
||||
@@ -916,6 +938,15 @@ const expectTargetSizeAsync = async (locator: Locator) => {
|
||||
expect(box.height).toBeGreaterThanOrEqual(24);
|
||||
};
|
||||
|
||||
const expectLogicalTargetSizeAsync = async (locator: Locator, minimumSize: number) => {
|
||||
const dimensions = await locator.evaluate((element) => ({
|
||||
width: (element as HTMLElement).offsetWidth,
|
||||
height: (element as HTMLElement).offsetHeight,
|
||||
}));
|
||||
expect(dimensions.width).toBeGreaterThanOrEqual(minimumSize);
|
||||
expect(dimensions.height).toBeGreaterThanOrEqual(minimumSize);
|
||||
};
|
||||
|
||||
const readGridLayoutAsync = async (locator: Locator) => {
|
||||
const [x, y, width, height] = await Promise.all([
|
||||
locator.getAttribute("data-grid-x"),
|
||||
|
||||
Reference in new issue
Block a user