feat: rebuild board grid experience
This commit is contained in:
1 parent
5dc4dbeaef
commit
0110a2ef63
122 files changed
+9582
-4063
No files matched your search
@@ -0,0 +1,27 @@
|
||||
# Fixed dashboard geometry
|
||||
|
||||
The board uses fixed logical units:
|
||||
|
||||
- one `1 × 1` item is exactly `200 × 200` logical pixels;
|
||||
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
|
||||
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
|
||||
- the canvas is transformed once with one uniform scale derived from its available width.
|
||||
|
||||
Items keep the same logical dimensions at every viewport. The viewport reserves
|
||||
the painted size while the inner canvas remains at its logical dimensions, so a
|
||||
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
|
||||
stable.
|
||||
|
||||
The read-only renderer should:
|
||||
|
||||
1. convert persisted `xOffset/yOffset/width/height` values to `x/y/w/h`;
|
||||
2. apply collapsed container heights and call `getCollapsedDisplayLayout`;
|
||||
3. render DOM children in `sortGridPlacementsForReadingOrder` order;
|
||||
4. position children with `getLogicalItemStyle`;
|
||||
5. wrap them with `getScaledCanvasStyles` and the read-only semantic helpers.
|
||||
|
||||
Edit mode can use any interaction library, but its final coordinates should be
|
||||
normalized through this core. `placeGridItem` is the deterministic
|
||||
keyboard/numeric fallback: the active item is pinned while collisions move out
|
||||
of the way and all other items compact vertically. Pointer coordinates from a
|
||||
scaled editor can be converted with `toLogicalCanvasPoint`.
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { BoardLane } from "@homarr/definitions";
|
||||
import { getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions";
|
||||
|
||||
import type { Board, EmptySection } from "~/app/[locale]/boards/_types";
|
||||
import { getLogicalTrackSize, getLayoutRowCount, normalizeGridPlacement } from "./geometry";
|
||||
import { getCollapsedDisplayLayout } from "./reflow";
|
||||
import type { GridPlacement } from "./types";
|
||||
|
||||
export { getBoardLaneColumnCount };
|
||||
|
||||
export const getRootSectionForLane = (board: Board, lane: BoardLane): EmptySection | undefined => {
|
||||
const roots = board.sections.filter(
|
||||
(section): section is EmptySection => section.kind === "empty" && getRootSectionLane(section.xOffset) === lane,
|
||||
);
|
||||
if (roots.length > 1) {
|
||||
throw new Error(`Board "${board.id}" has multiple ${lane} canvas roots`);
|
||||
}
|
||||
|
||||
return roots[0];
|
||||
};
|
||||
|
||||
export const getInitialBoardLogicalHeight = (board: Board, layoutId: string) => {
|
||||
const layout = board.layouts.find((candidate) => candidate.id === layoutId);
|
||||
if (!layout) return 0;
|
||||
|
||||
const rowCounts = (["left", "main", "right"] as const).flatMap((lane) => {
|
||||
const columnCount = getBoardLaneColumnCount(layout, lane);
|
||||
const root = getRootSectionForLane(board, lane);
|
||||
if (columnCount === 0 || !root) return [];
|
||||
|
||||
const placements: GridPlacement[] = [
|
||||
...board.items.flatMap((item) => {
|
||||
const itemLayout = item.layouts.find(
|
||||
(candidate) => candidate.layoutId === layoutId && candidate.sectionId === root.id,
|
||||
);
|
||||
return itemLayout
|
||||
? [
|
||||
normalizeGridPlacement(
|
||||
{
|
||||
id: item.id,
|
||||
type: "item" as const,
|
||||
x: itemLayout.xOffset,
|
||||
y: itemLayout.yOffset,
|
||||
w: itemLayout.width,
|
||||
h: itemLayout.height,
|
||||
},
|
||||
columnCount,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
...board.sections.flatMap((section) => {
|
||||
if (section.kind !== "dynamic") return [];
|
||||
const sectionLayout = section.layouts.find(
|
||||
(candidate) => candidate.layoutId === layoutId && candidate.parentSectionId === root.id,
|
||||
);
|
||||
return sectionLayout
|
||||
? [
|
||||
normalizeGridPlacement(
|
||||
{
|
||||
id: section.id,
|
||||
type: "section" as const,
|
||||
x: sectionLayout.xOffset,
|
||||
y: sectionLayout.yOffset,
|
||||
w: sectionLayout.width,
|
||||
h: sectionLayout.height,
|
||||
},
|
||||
columnCount,
|
||||
),
|
||||
]
|
||||
: [];
|
||||
}),
|
||||
];
|
||||
const collapsedSectionIds = new Set(
|
||||
board.sections
|
||||
.filter(
|
||||
(section) =>
|
||||
section.kind === "dynamic" &&
|
||||
section.collapsed &&
|
||||
section.options.collapsible &&
|
||||
placements.some((placement) => placement.id === section.id),
|
||||
)
|
||||
.map((section) => section.id),
|
||||
);
|
||||
const displayPlacements =
|
||||
collapsedSectionIds.size > 0
|
||||
? getCollapsedDisplayLayout(placements, { columnCount, collapsedItemIds: collapsedSectionIds })
|
||||
: placements;
|
||||
|
||||
return [getLayoutRowCount(displayPlacements)];
|
||||
});
|
||||
|
||||
return getLogicalTrackSize(Math.max(0, ...rowCounts));
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* A grid unit is a fixed logical size. The canvas is scaled as one surface;
|
||||
* individual cards never change their internal layout at different viewports.
|
||||
*/
|
||||
export const LOGICAL_GRID_CELL_SIZE = 200;
|
||||
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;
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { CSSProperties } from "react";
|
||||
|
||||
import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP, LOGICAL_GRID_PITCH } from "./constants";
|
||||
import type { GridCoordinates, GridPlacement } from "./types";
|
||||
|
||||
export const getLogicalTrackSize = (trackCount: number) => {
|
||||
const normalizedTrackCount = toNonNegativeInteger(trackCount);
|
||||
if (normalizedTrackCount === 0) return 0;
|
||||
|
||||
return normalizedTrackCount * LOGICAL_GRID_CELL_SIZE + (normalizedTrackCount - 1) * LOGICAL_GRID_GAP;
|
||||
};
|
||||
|
||||
export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSProperties => ({
|
||||
position: "absolute",
|
||||
left: x * LOGICAL_GRID_PITCH,
|
||||
top: y * LOGICAL_GRID_PITCH,
|
||||
width: getLogicalTrackSize(w),
|
||||
height: getLogicalTrackSize(h),
|
||||
});
|
||||
|
||||
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
|
||||
placements.reduce((rowCount, placement) => Math.max(rowCount, placement.y + placement.h), 0);
|
||||
|
||||
export const normalizeGridPlacement = <TPlacement extends GridPlacement>(
|
||||
placement: TPlacement,
|
||||
columnCount: number,
|
||||
): TPlacement => {
|
||||
const normalizedColumnCount = toPositiveInteger(columnCount, "columnCount");
|
||||
const width = Math.min(toPositiveInteger(placement.w, "placement.w"), normalizedColumnCount);
|
||||
const x = Math.min(Math.max(0, toNonNegativeInteger(placement.x)), normalizedColumnCount - width);
|
||||
|
||||
return {
|
||||
...placement,
|
||||
x,
|
||||
y: toNonNegativeInteger(placement.y),
|
||||
w: width,
|
||||
h: toPositiveInteger(placement.h, "placement.h"),
|
||||
};
|
||||
};
|
||||
|
||||
const toNonNegativeInteger = (value: number) => Math.max(0, Math.trunc(toFiniteNumber(value, "value")));
|
||||
|
||||
const toPositiveInteger = (value: number, name: string) => {
|
||||
const integer = Math.trunc(toFiniteNumber(value, name));
|
||||
if (integer < 1) throw new RangeError(`${name} must be at least 1`);
|
||||
|
||||
return integer;
|
||||
};
|
||||
|
||||
const toFiniteNumber = (value: number, name: string) => {
|
||||
if (!Number.isFinite(value)) throw new TypeError(`${name} must be finite`);
|
||||
|
||||
return value;
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from "./constants";
|
||||
export * from "./board-lanes";
|
||||
export * from "./geometry";
|
||||
export * from "./reflow";
|
||||
export * from "./semantics";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,140 @@
|
||||
import { COLLAPSED_SECTION_ROW_COUNT } from "./constants";
|
||||
import { normalizeGridPlacement } from "./geometry";
|
||||
import type { GridPlacement } from "./types";
|
||||
|
||||
interface ReflowOptions {
|
||||
columnCount: number;
|
||||
/**
|
||||
* A pinned item keeps its requested x/y while every other item compacts
|
||||
* around it. Use this for the item currently being moved or resized.
|
||||
*/
|
||||
pinnedItemId?: string;
|
||||
}
|
||||
|
||||
interface CollapsedLayoutOptions {
|
||||
columnCount: number;
|
||||
collapsedItemIds: ReadonlySet<string>;
|
||||
collapsedRowCount?: number;
|
||||
}
|
||||
|
||||
export const doGridPlacementsOverlap = (first: GridPlacement, second: GridPlacement) =>
|
||||
first.x < second.x + second.w &&
|
||||
first.x + first.w > second.x &&
|
||||
first.y < second.y + second.h &&
|
||||
first.y + first.h > second.y;
|
||||
|
||||
/**
|
||||
* Resolves overlaps and vertically compacts items. Ordering is based on the
|
||||
* persisted position and stable id, so the result does not depend on React or
|
||||
* database array order.
|
||||
*/
|
||||
export const reflowVerticalLayout = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
{ columnCount, pinnedItemId }: ReflowOptions,
|
||||
): TPlacement[] => {
|
||||
assertUniqueIds(placements);
|
||||
|
||||
const normalized = placements.map((placement) => normalizeGridPlacement(placement, columnCount));
|
||||
const pinned = pinnedItemId ? normalized.find((placement) => placement.id === pinnedItemId) : undefined;
|
||||
|
||||
if (pinnedItemId && !pinned) {
|
||||
throw new Error(`Pinned grid item "${pinnedItemId}" does not exist`);
|
||||
}
|
||||
|
||||
const placed: TPlacement[] = pinned ? [pinned] : [];
|
||||
const candidates = normalized.filter((placement) => placement.id !== pinnedItemId).toSorted(comparePlacements);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
placed.push({
|
||||
...candidate,
|
||||
y: findFirstAvailableRow(candidate, placed),
|
||||
});
|
||||
}
|
||||
|
||||
const byId = new Map(placed.map((placement) => [placement.id, placement]));
|
||||
return normalized.map((placement) => getRequiredPlacement(byId, placement.id));
|
||||
};
|
||||
|
||||
/**
|
||||
* Produces the view-only layout after inline containers are collapsed. Only
|
||||
* display geometry changes; callers retain the persisted expanded height.
|
||||
*/
|
||||
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");
|
||||
}
|
||||
|
||||
return reflowVerticalLayout(
|
||||
placements.map((placement) =>
|
||||
collapsedItemIds.has(placement.id)
|
||||
? {
|
||||
...placement,
|
||||
h: collapsedRowCount,
|
||||
}
|
||||
: placement,
|
||||
),
|
||||
{ columnCount },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies the requested placement and pins it while neighbors compact into
|
||||
* free space or move down. This is the deterministic fallback used by
|
||||
* keyboard/numeric editing and can also validate a drag library result.
|
||||
*/
|
||||
export const placeGridItem = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
nextPlacement: TPlacement,
|
||||
columnCount: number,
|
||||
) => {
|
||||
if (!placements.some((placement) => placement.id === nextPlacement.id)) {
|
||||
throw new Error(`Grid item "${nextPlacement.id}" does not exist`);
|
||||
}
|
||||
|
||||
return reflowVerticalLayout(
|
||||
placements.map((placement) => (placement.id === nextPlacement.id ? nextPlacement : placement)),
|
||||
{
|
||||
columnCount,
|
||||
pinnedItemId: nextPlacement.id,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const sortGridPlacementsForReadingOrder = <TPlacement extends GridPlacement>(
|
||||
placements: readonly TPlacement[],
|
||||
) => placements.toSorted(comparePlacements);
|
||||
|
||||
const findFirstAvailableRow = (candidate: GridPlacement, placed: readonly GridPlacement[]) => {
|
||||
let y = 0;
|
||||
for (;;) {
|
||||
const atRow = { ...candidate, y };
|
||||
const collisions = placed.filter((placement) => doGridPlacementsOverlap(atRow, placement));
|
||||
if (collisions.length === 0) return y;
|
||||
|
||||
y = Math.max(...collisions.map((placement) => placement.y + placement.h));
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
const assertUniqueIds = (placements: readonly GridPlacement[]) => {
|
||||
const ids = new Set<string>();
|
||||
for (const placement of placements) {
|
||||
if (ids.has(placement.id)) throw new Error(`Duplicate grid item id "${placement.id}"`);
|
||||
ids.add(placement.id);
|
||||
}
|
||||
};
|
||||
|
||||
const getRequiredPlacement = <TPlacement extends GridPlacement>(
|
||||
placements: ReadonlyMap<string, TPlacement>,
|
||||
id: string,
|
||||
) => {
|
||||
const placement = placements.get(id);
|
||||
if (!placement) throw new Error(`Grid item "${id}" was lost during layout`);
|
||||
|
||||
return placement;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
.viewport {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
overscroll-behavior-inline: contain;
|
||||
}
|
||||
|
||||
.viewport[data-board-hydrated="false"] {
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.sizer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
position: absolute;
|
||||
inset: 0 auto auto 0;
|
||||
transform-origin: top left;
|
||||
will-change: transform;
|
||||
|
||||
/*
|
||||
* Keep dashboard UI at its normal readable density while the fixed canvas
|
||||
* is fitted below 100%. Above 100%, the canvas still behaves like browser
|
||||
* zoom and enlarges everything together.
|
||||
*/
|
||||
--mantine-scale: var(--board-canvas-ui-scale, 1);
|
||||
--mantine-spacing-xs: calc(0.625rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-sm: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h4-font-size: calc(1.125rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
|
||||
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.canvas {
|
||||
will-change: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
"use client";
|
||||
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Box } from "@mantine/core";
|
||||
import { useElementSize } from "@mantine/hooks";
|
||||
|
||||
import { MIN_ACCESSIBLE_CANVAS_SCALE } from "./constants";
|
||||
import classes from "./scaled-board-canvas.module.css";
|
||||
|
||||
export { MIN_ACCESSIBLE_CANVAS_SCALE };
|
||||
|
||||
export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth: number) => {
|
||||
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
|
||||
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
|
||||
|
||||
return Math.max(MIN_ACCESSIBLE_CANVAS_SCALE, availableWidth / logicalWidth);
|
||||
};
|
||||
|
||||
export const calculateBoardUiScale = (canvasScale: number) => {
|
||||
if (!Number.isFinite(canvasScale) || canvasScale <= 0) return 1;
|
||||
|
||||
return canvasScale < 1 ? 1 / canvasScale : 1;
|
||||
};
|
||||
|
||||
interface ScaledBoardCanvasProps {
|
||||
logicalWidth: number;
|
||||
initialLogicalHeight: number;
|
||||
initialAvailableWidth: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scales the complete board as one surface while keeping widget layout in
|
||||
* fixed logical pixels. The scale floor deliberately allows horizontal
|
||||
* overflow so browser zoom still magnifies content instead of being cancelled
|
||||
* by responsive downscaling.
|
||||
*/
|
||||
export const ScaledBoardCanvas = ({
|
||||
logicalWidth,
|
||||
initialLogicalHeight,
|
||||
initialAvailableWidth,
|
||||
label,
|
||||
children,
|
||||
}: PropsWithChildren<ScaledBoardCanvasProps>) => {
|
||||
const { ref: viewportRef, width: availableWidth } = useElementSize<HTMLDivElement>();
|
||||
const { ref: canvasRef, height: logicalHeight } = useElementSize<HTMLDivElement>();
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
|
||||
const resolvedAvailableWidth = availableWidth > 0 ? availableWidth : initialAvailableWidth;
|
||||
const resolvedLogicalHeight = logicalHeight > 0 ? logicalHeight : initialLogicalHeight;
|
||||
const scale = useMemo(
|
||||
() => calculateBoardCanvasScale(resolvedAvailableWidth, logicalWidth),
|
||||
[logicalWidth, resolvedAvailableWidth],
|
||||
);
|
||||
const uiScale = calculateBoardUiScale(scale);
|
||||
const visualWidth = logicalWidth * scale;
|
||||
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="section"
|
||||
ref={viewportRef}
|
||||
className={classes.viewport}
|
||||
data-testid="board-canvas"
|
||||
data-board-hydrated={isHydrated ? "true" : "false"}
|
||||
data-canvas-scale={scale}
|
||||
data-canvas-overflow={hasHorizontalOverflow ? "true" : "false"}
|
||||
data-canvas-initial-height={initialLogicalHeight}
|
||||
data-canvas-initial-width={initialAvailableWidth}
|
||||
aria-label={label}
|
||||
>
|
||||
<Box className={classes.sizer} w={visualWidth} h={resolvedLogicalHeight * scale}>
|
||||
<Box
|
||||
ref={canvasRef}
|
||||
className={classes.canvas}
|
||||
w={logicalWidth}
|
||||
style={{
|
||||
"--board-canvas-ui-scale": uiScale,
|
||||
transform: `scale(${scale})`,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { GridPlacement } from "./types";
|
||||
|
||||
interface ReadonlyCanvasAttributesInput {
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface EditableCanvasAttributesInput extends ReadonlyCanvasAttributesInput {
|
||||
columnCount: number;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
interface EditableGridCellAttributesInput {
|
||||
label: string;
|
||||
placement: GridPlacement;
|
||||
}
|
||||
|
||||
/**
|
||||
* View mode is a labelled region, not an ARIA grid. It keeps the native
|
||||
* semantics and focus behavior of links, buttons and widget controls inside.
|
||||
*/
|
||||
export const getReadonlyCanvasAttributes = ({ label }: ReadonlyCanvasAttributesInput) =>
|
||||
({
|
||||
role: "region",
|
||||
"aria-label": label,
|
||||
"data-grid-readonly": "true",
|
||||
}) as const;
|
||||
|
||||
export const getReadonlyGridItemAttributes = (placement: GridPlacement) =>
|
||||
({
|
||||
"data-grid-id": placement.id,
|
||||
"data-grid-x": placement.x,
|
||||
"data-grid-y": placement.y,
|
||||
"data-grid-w": placement.w,
|
||||
"data-grid-h": placement.h,
|
||||
}) as const;
|
||||
|
||||
/**
|
||||
* Edit mode remains a labelled region. Dashboard placement is not an ARIA
|
||||
* data grid: arrow keys edit geometry rather than navigate rows and columns.
|
||||
* Each tile is one focusable direct-manipulation surface, while controls inside
|
||||
* it keep their native semantics and tab order.
|
||||
*/
|
||||
export const getEditableCanvasAttributes = ({ label }: EditableCanvasAttributesInput) =>
|
||||
({
|
||||
role: "region",
|
||||
"aria-label": label,
|
||||
"data-grid-editable": "true",
|
||||
}) as const;
|
||||
|
||||
export const getEditableGridCellAttributes = ({ label, placement }: EditableGridCellAttributesInput) =>
|
||||
({
|
||||
role: "group",
|
||||
"aria-label": label,
|
||||
tabIndex: 0,
|
||||
...getReadonlyGridItemAttributes(placement),
|
||||
}) as const;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { Board } from "~/app/[locale]/boards/_types";
|
||||
import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
|
||||
import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock";
|
||||
import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
|
||||
import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock";
|
||||
import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, getRootSectionForLane } from "../index";
|
||||
|
||||
describe("board lanes", () => {
|
||||
test("gutters consume columns from the main canvas", () => {
|
||||
const layout = {
|
||||
id: "layout",
|
||||
name: "Base",
|
||||
columnCount: 12,
|
||||
leftGutterColumnCount: 2,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
} satisfies Board["layouts"][number];
|
||||
|
||||
expect(getBoardLaneColumnCount(layout, "left")).toBe(2);
|
||||
expect(getBoardLaneColumnCount(layout, "main")).toBe(7);
|
||||
expect(getBoardLaneColumnCount(layout, "right")).toBe(3);
|
||||
});
|
||||
|
||||
test("clamps invalid gutters while preserving one main column", () => {
|
||||
const layout = {
|
||||
id: "layout",
|
||||
name: "Base",
|
||||
columnCount: 4,
|
||||
leftGutterColumnCount: 3,
|
||||
rightGutterColumnCount: 3,
|
||||
breakpoint: 0,
|
||||
} satisfies Board["layouts"][number];
|
||||
|
||||
expect(getBoardLaneColumnCount(layout, "left")).toBe(3);
|
||||
expect(getBoardLaneColumnCount(layout, "main")).toBe(1);
|
||||
expect(getBoardLaneColumnCount(layout, "right")).toBe(0);
|
||||
});
|
||||
|
||||
test("rejects ambiguous duplicate roots", () => {
|
||||
const board = new BoardMockBuilder()
|
||||
.addSection(new EmptySectionMockBuilder({ id: "main-a" }).build())
|
||||
.addSection(new EmptySectionMockBuilder({ id: "main-b" }).build())
|
||||
.build();
|
||||
|
||||
expect(() => getRootSectionForLane(board, "main")).toThrow("multiple main canvas roots");
|
||||
});
|
||||
|
||||
test("calculates a stable server height from the tallest visible lane", () => {
|
||||
const layoutId = "layout";
|
||||
const mainSectionId = "main";
|
||||
const leftSectionId = "left";
|
||||
const dynamicSectionId = "dynamic";
|
||||
const board = new BoardMockBuilder()
|
||||
.addSection(new EmptySectionMockBuilder({ id: mainSectionId }).build())
|
||||
.addSection(new EmptySectionMockBuilder({ id: leftSectionId, xOffset: -1 }).build())
|
||||
.addSection(
|
||||
new DynamicSectionMockBuilder({
|
||||
id: dynamicSectionId,
|
||||
collapsed: true,
|
||||
options: { collapsible: true },
|
||||
})
|
||||
.addLayout({
|
||||
layoutId,
|
||||
parentSectionId: mainSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 2,
|
||||
height: 3,
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder()
|
||||
.addLayout({
|
||||
layoutId,
|
||||
sectionId: mainSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 3,
|
||||
width: 1,
|
||||
height: 1,
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.addItem(
|
||||
new ItemMockBuilder()
|
||||
.addLayout({
|
||||
layoutId,
|
||||
sectionId: leftSectionId,
|
||||
xOffset: 0,
|
||||
yOffset: 0,
|
||||
width: 1,
|
||||
height: 4,
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.build();
|
||||
board.layouts = [
|
||||
{
|
||||
id: layoutId,
|
||||
name: "Base",
|
||||
columnCount: 8,
|
||||
leftGutterColumnCount: 1,
|
||||
rightGutterColumnCount: 0,
|
||||
breakpoint: 0,
|
||||
},
|
||||
];
|
||||
|
||||
expect(getInitialBoardLogicalHeight(board, layoutId)).toBe(872);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getLayoutRowCount,
|
||||
getLogicalItemStyle,
|
||||
getLogicalTrackSize,
|
||||
normalizeGridPlacement,
|
||||
} from "../index";
|
||||
|
||||
describe("fixed dashboard geometry", () => {
|
||||
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
|
||||
expect(getLogicalTrackSize(1)).toBe(200);
|
||||
expect(getLogicalTrackSize(2)).toBe(424);
|
||||
expect(getLogicalTrackSize(3)).toBe(648);
|
||||
|
||||
expect(getLogicalItemStyle({ x: 1, y: 2, w: 2, h: 3 })).toEqual({
|
||||
position: "absolute",
|
||||
left: 224,
|
||||
top: 448,
|
||||
width: 424,
|
||||
height: 648,
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizes invalid bounds and derives the required row count", () => {
|
||||
const placement = normalizeGridPlacement({ id: "wide", x: 7, y: -2, w: 8, h: 2 }, 4);
|
||||
|
||||
expect(placement).toEqual({ id: "wide", x: 0, y: 0, w: 4, h: 2 });
|
||||
expect(getLayoutRowCount([placement, { x: 0, y: 4, w: 1, h: 3 }])).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
doGridPlacementsOverlap,
|
||||
getCollapsedDisplayLayout,
|
||||
placeGridItem,
|
||||
reflowVerticalLayout,
|
||||
sortGridPlacementsForReadingOrder,
|
||||
} from "../index";
|
||||
import type { GridPlacement } from "../index";
|
||||
|
||||
describe("dashboard layout reflow", () => {
|
||||
test("compacts each horizontal lane and removes collisions", () => {
|
||||
const result = reflowVerticalLayout(
|
||||
[
|
||||
{ id: "a", x: 0, y: 0, w: 1, h: 1 },
|
||||
{ id: "b", x: 0, y: 3, w: 1, h: 1 },
|
||||
{ id: "c", x: 1, y: 3, w: 1, h: 1 },
|
||||
],
|
||||
{ columnCount: 2 },
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "a", x: 0, y: 0, w: 1, h: 1 },
|
||||
{ id: "b", x: 0, y: 1, w: 1, h: 1 },
|
||||
{ id: "c", x: 1, y: 0, w: 1, h: 1 },
|
||||
]);
|
||||
expect(hasOverlap(result)).toBe(false);
|
||||
});
|
||||
|
||||
test("shrinks a collapsed inline container and pulls neighbors up", () => {
|
||||
const result = getCollapsedDisplayLayout(
|
||||
[
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
|
||||
{ id: "below", x: 0, y: 3, w: 2, h: 1 },
|
||||
{ id: "other-lane", x: 2, y: 4, w: 1, h: 1 },
|
||||
],
|
||||
{
|
||||
columnCount: 3,
|
||||
collapsedItemIds: new Set(["section"]),
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 1 },
|
||||
{ id: "below", x: 0, y: 1, w: 2, h: 1 },
|
||||
{ id: "other-lane", x: 2, y: 0, w: 1, h: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test("pins a moved or expanded item and pushes collisions out of the way", () => {
|
||||
const result = placeGridItem(
|
||||
[
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 1 },
|
||||
{ id: "neighbor", x: 0, y: 1, w: 2, h: 1 },
|
||||
{ id: "side", x: 2, y: 2, w: 1, h: 1 },
|
||||
],
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
|
||||
3,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ id: "section", x: 0, y: 0, w: 2, h: 3 },
|
||||
{ id: "neighbor", x: 0, y: 3, w: 2, h: 1 },
|
||||
{ id: "side", x: 2, y: 0, w: 1, h: 1 },
|
||||
]);
|
||||
expect(hasOverlap(result)).toBe(false);
|
||||
});
|
||||
|
||||
test("is deterministic regardless of input array order", () => {
|
||||
const first = [
|
||||
{ id: "b", x: 0, y: 2, w: 1, h: 1 },
|
||||
{ id: "a", x: 0, y: 2, w: 1, h: 1 },
|
||||
{ id: "c", x: 1, y: 8, w: 1, h: 1 },
|
||||
];
|
||||
const second = first.toReversed();
|
||||
|
||||
expect(byId(reflowVerticalLayout(first, { columnCount: 2 }))).toEqual(
|
||||
byId(reflowVerticalLayout(second, { columnCount: 2 })),
|
||||
);
|
||||
});
|
||||
|
||||
test("provides row-major DOM order for the read-only renderer", () => {
|
||||
const items = [
|
||||
{ id: "last", x: 0, y: 2, w: 1, h: 1 },
|
||||
{ id: "right", x: 1, y: 0, w: 1, h: 1 },
|
||||
{ id: "left", x: 0, y: 0, w: 1, h: 1 },
|
||||
];
|
||||
|
||||
expect(sortGridPlacementsForReadingOrder(items).map((item) => item.id)).toEqual(["left", "right", "last"]);
|
||||
});
|
||||
});
|
||||
|
||||
const hasOverlap = (placements: readonly GridPlacement[]) =>
|
||||
placements.some((placement, index) =>
|
||||
placements.slice(index + 1).some((otherPlacement) => doGridPlacementsOverlap(placement, otherPlacement)),
|
||||
);
|
||||
|
||||
const byId = (placements: readonly GridPlacement[]) =>
|
||||
Object.fromEntries(placements.map((placement) => [placement.id, placement]));
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { calculateBoardCanvasScale, calculateBoardUiScale, MIN_ACCESSIBLE_CANVAS_SCALE } from "../scaled-board-canvas";
|
||||
|
||||
describe("board canvas scale policy", () => {
|
||||
test("fits the logical canvas exactly when the viewport can preserve the minimum scale", () => {
|
||||
const scale = calculateBoardCanvasScale(840, 1000);
|
||||
|
||||
expect(scale).toBe(0.84);
|
||||
expect(1000 * scale).toBe(840);
|
||||
});
|
||||
|
||||
test("scales up uniformly on a larger screen", () => {
|
||||
expect(calculateBoardCanvasScale(1800, 1000)).toBe(1.8);
|
||||
});
|
||||
|
||||
test("stops counter-scaling at 0.75 and lets the canvas overflow", () => {
|
||||
const availableWidth = 600;
|
||||
const logicalWidth = 1000;
|
||||
const scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
|
||||
|
||||
expect(scale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
|
||||
expect(logicalWidth * scale).toBeGreaterThan(availableWidth);
|
||||
});
|
||||
|
||||
test("keeps content materially magnified at 200% effective zoom", () => {
|
||||
const logicalWidth = 1696;
|
||||
const normalAvailableWidth = 1872;
|
||||
const normalScale = calculateBoardCanvasScale(normalAvailableWidth, logicalWidth);
|
||||
const zoomedScale = calculateBoardCanvasScale(normalAvailableWidth / 2, logicalWidth);
|
||||
const normalPaintedCellSize = 200 * normalScale;
|
||||
const zoomedPaintedCellSize = 200 * zoomedScale * 2;
|
||||
|
||||
expect(zoomedScale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
|
||||
expect(zoomedPaintedCellSize).toBeGreaterThan(normalPaintedCellSize * 1.25);
|
||||
});
|
||||
|
||||
test.each([
|
||||
[0, 1000],
|
||||
[1000, 0],
|
||||
[Number.NaN, 1000],
|
||||
[1000, Number.POSITIVE_INFINITY],
|
||||
])("uses a stable initial scale for unavailable geometry (%s, %s)", (availableWidth, logicalWidth) => {
|
||||
expect(calculateBoardCanvasScale(availableWidth, logicalWidth)).toBe(1);
|
||||
});
|
||||
|
||||
test("keeps UI typography and density at 100% while fitting the canvas down", () => {
|
||||
expect(calculateBoardUiScale(0.75)).toBeCloseTo(4 / 3);
|
||||
expect(calculateBoardUiScale(0.84)).toBeCloseTo(1 / 0.84);
|
||||
expect(calculateBoardUiScale(1)).toBe(1);
|
||||
expect(calculateBoardUiScale(1.5)).toBe(1);
|
||||
});
|
||||
|
||||
test.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
|
||||
"uses stable UI density for an invalid canvas scale (%s)",
|
||||
(scale) => {
|
||||
expect(calculateBoardUiScale(scale)).toBe(1);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
getEditableCanvasAttributes,
|
||||
getEditableGridCellAttributes,
|
||||
getReadonlyCanvasAttributes,
|
||||
getReadonlyGridItemAttributes,
|
||||
} from "../index";
|
||||
|
||||
describe("dashboard layout semantics", () => {
|
||||
const placement = { id: "weather", x: 1, y: 2, w: 3, h: 2 };
|
||||
|
||||
test("keeps read-only mode a native labelled region", () => {
|
||||
expect(getReadonlyCanvasAttributes({ label: "Home dashboard" })).toEqual({
|
||||
role: "region",
|
||||
"aria-label": "Home dashboard",
|
||||
"data-grid-readonly": "true",
|
||||
});
|
||||
expect(getReadonlyGridItemAttributes(placement)).toEqual({
|
||||
"data-grid-id": "weather",
|
||||
"data-grid-x": 1,
|
||||
"data-grid-y": 2,
|
||||
"data-grid-w": 3,
|
||||
"data-grid-h": 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("keeps edit mode a labelled region with spatial item metadata", () => {
|
||||
expect(
|
||||
getEditableCanvasAttributes({
|
||||
label: "Edit home dashboard",
|
||||
columnCount: 12,
|
||||
rowCount: 8,
|
||||
}),
|
||||
).toEqual({
|
||||
role: "region",
|
||||
"aria-label": "Edit home dashboard",
|
||||
"data-grid-editable": "true",
|
||||
});
|
||||
|
||||
expect(
|
||||
getEditableGridCellAttributes({
|
||||
label: "Weather widget",
|
||||
placement,
|
||||
}),
|
||||
).toEqual({
|
||||
role: "group",
|
||||
"aria-label": "Weather widget",
|
||||
tabIndex: 0,
|
||||
"data-grid-id": "weather",
|
||||
"data-grid-x": 1,
|
||||
"data-grid-y": 2,
|
||||
"data-grid-w": 3,
|
||||
"data-grid-h": 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface GridCoordinates {
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
export interface GridPlacement extends GridCoordinates {
|
||||
id: string;
|
||||
}
|
||||
Reference in new issue
Block a user