summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/ui/HomeTileGrid.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-09-07 17:08:52 +0900
committerAdam Malczewski <[email protected]>2026-09-07 17:08:52 +0900
commitc43250c16356be523201c2a685d527936aeea0bd (patch)
treee3f6eeff9e2a6fec2f094df8591df0b038b5c5cd /src/features/workspaces/ui/HomeTileGrid.test.ts
parent6ae43c119ad51e99d1f4a4ede21b6ba4eb44c3b1 (diff)
downloaddispatch-web-c43250c16356be523201c2a685d527936aeea0bd.tar.gz
dispatch-web-c43250c16356be523201c2a685d527936aeea0bd.zip
feat(workspaces): pinned tiles with displacement reflow + FLIP drag preview
Tiles can now be dropped onto specific cells; empty cells are real estate that persists (notes/pinned-tiles-plan.md). Data (v2): each tile carries an optional pinned anchor in a UNIVERSAL 4-row frame (tall folds its 2 columns onto frame column 1; a wide col>1 pin reads as unpinned in tall). v1 documents migrate in memory to all-unpinned (render-identical) and write back as v2 on the next change. sanitizeLayout drops garbage anchors. Packing: pinned-first (first-wins on overlap, one-ply), then unpinned tiles first-fit into the REMAINING cells — holes between pins count and never compact. displacePlace: the user-specified semantics — the dragged tile leaves a gap at its origin (nothing slides backward), anchors at the drop cell, and the contiguous filled run pushes FORWARD until the first empty cell absorbs it (X_X + Y@1 → YXX; _XZ_ + Y@2 → _YXZ). placeAtCell commits the displacement and pins every tile; pinAtFirstEmpty pins the create-flow tile at the first truly-empty slot; sortByRecentActivity now also clears pins (an explicit arrangement reset — the menu item says so). Drag UX: pointer → cell hit-math (empty cells are drop targets); a faint dot marks every cell center during a drag (CSS radial-gradient overlay, one extra pitch of end padding for the append slot); the dragged tile element itself follows the pointer (lifted via transform, pointer-events none); a dashed tone-colored ghost marks the landing footprint. The preview recomputes from the COMMITTED layout on every hover-cell change (as if the user moved straight from the original to there) while GSAP Flip animates from wherever the tiles currently are (re-entrant — mid-flight positions captured). Escape/pointercancel discards the preview and flies the tile home; a drop settles it onto its new cell. Tests: displacement (both user examples + internal moves + spans + tall stream), pinned pack (anchors, gaps kept, first-wins, universal-frame mapping, deleted-leaves-holes), v1→v2 migration + anchor sanitization, store placeAtCell/no-op write guard, Escape-cancels-drag component test. 1689 green.
Diffstat (limited to 'src/features/workspaces/ui/HomeTileGrid.test.ts')
-rw-r--r--src/features/workspaces/ui/HomeTileGrid.test.ts43
1 files changed, 31 insertions, 12 deletions
diff --git a/src/features/workspaces/ui/HomeTileGrid.test.ts b/src/features/workspaces/ui/HomeTileGrid.test.ts
index 3229d4e..d70c673 100644
--- a/src/features/workspaces/ui/HomeTileGrid.test.ts
+++ b/src/features/workspaces/ui/HomeTileGrid.test.ts
@@ -41,7 +41,7 @@ function baseProps() {
store: fakeStore(),
onOpen: vi.fn(),
onSelect: vi.fn(),
- onReorder: vi.fn(),
+ onPlace: vi.fn(),
onRenameEnd: vi.fn(),
};
}
@@ -98,32 +98,51 @@ describe("HomeTileGrid", () => {
expect(props.onOpen).not.toHaveBeenCalled();
});
- it("dragging shows the transient ghost, and dropping reorders (when over a target)", async () => {
+ it("dragging shows the ghost + follow transform, and dropping PLACES at the hovered cell", async () => {
const props = baseProps();
const { container } = render(HomeTileGrid, {
props: { ...props, placements: [placement("a", 1, 1), placement("b", 2, 1)] },
});
const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
- // jsdom has no elementFromPoint hit-testing → targetIndex stays -1 (no-op),
- // but the GHOST must appear while dragging and disappear on drop.
- Object.defineProperty(document, "elementFromPoint", {
- configurable: true,
- value: () => null,
- });
+ // The cell math resolves against the grid element's rect (jsdom: 0s).
fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" });
fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 });
- expect(container.querySelector(".pointer-events-none.fixed")).not.toBeNull();
+ // The dashed landing ghost appears…
+ const ghost = container.querySelector("[aria-hidden='true'].border-dashed");
+ expect(ghost).not.toBeNull();
+ // …and the dragged tile itself follows the pointer (transform on the tile).
+ const tile = container.querySelector("[data-tile-id='a']");
+ expect(tile?.getAttribute("style")).toContain("translate(");
fireEvent.pointerUp(wrapper);
- expect(container.querySelector(".pointer-events-none.fixed")).toBeNull();
- // No hit target → no reorder call.
- expect(props.onReorder).not.toHaveBeenCalled();
+ // The ghost + follow disappear on drop…
+ expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull();
+ // …and the drop committed a PLACE at the hovered cell (a lands where it
+ // already was — the pointer hovered its own slot; a no-move drop).
+ expect(props.onPlace).toHaveBeenCalledWith("a", { col: 1, row: 1 });
// And no navigation either (a drag is not a tap).
expect(props.onOpen).not.toHaveBeenCalled();
});
+ it("Escape mid-drag CANCELS: no place commits and the tile un-lifts", async () => {
+ const props = baseProps();
+ const { container } = render(HomeTileGrid, {
+ props: { ...props, placements: [placement("a", 1, 1)] },
+ });
+
+ const wrapper = screen.getByRole("button", { name: "Open workspace Workspace A" });
+ fireEvent.pointerDown(wrapper, { button: 0, clientX: 10, clientY: 10, pointerType: "mouse" });
+ fireEvent.pointerMove(wrapper, { clientX: 80, clientY: 40 });
+
+ fireEvent.keyDown(window, { key: "Escape" });
+
+ expect(props.onPlace).not.toHaveBeenCalled();
+ // The drag visuals are gone (the tile is no longer lifted).
+ expect(container.querySelector("[aria-hidden='true'].border-dashed")).toBeNull();
+ });
+
it("a touch long-press lifts the tile and selects it (never opens)", async () => {
const props = baseProps();
render(HomeTileGrid, {