diff options
| author | Adam Malczewski <[email protected]> | 2026-09-07 11:47:18 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-09-07 11:47:18 +0900 |
| commit | 754450b539818f9ad94c9586888973b0edd74b68 (patch) | |
| tree | d852f9a8910f223e15a5e11ad04868b0d0a29804 /src/features/workspaces/logic/drag-controller.test.ts | |
| parent | 719f0038dbb09f0fe9b073c3dc8e4f403815223d (diff) | |
| download | dispatch-web-754450b539818f9ad94c9586888973b0edd74b68.tar.gz dispatch-web-754450b539818f9ad94c9586888973b0edd74b68.zip | |
feat(workspaces): Win8 Start-style tile-grid home page
Rebuild the workspace home as a Windows 8 Start-screen-style tile grid,
replacing the max-w-3xl vertical card list.
Pure core (zero DOM/Svelte/fetch, fully unit-tested):
- logic/tile-layout.ts — geometryFor(width, height) resolves WIDE (4 fixed
~120px rows, column-wise flow, 4x2 span cap) or TALL (2 fixed columns,
row-wise flow, 2x2 cap); pack() is the ONLY order->coordinates transform
(greedy first-fit in fill order, hole-free, render-clamped per geometry);
firstEmptySlot/insertAtFirstEmptySlot/mergeLayout/resizeTile/moveTile/
sortByRecentActivity/sanitizeLayout. Persisted data is
orientation-independent {version:1, tiles:[{id,colSpan,rowSpan,order}]} —
order + spans only, never positions; the same data renders in either
geometry (spans clamp for the render, persisted values untouched).
- logic/drag-controller.ts — pure pointer/press state machine: mouse
threshold-drag + tap-open; touch long-press lifts + selects, pre-lift
movement cancels so vertical scrolling never fights the gesture.
Shell:
- tile-layout-store.svelte.ts — thin reactive wrapper persisting through
the injected createLocalStore adapter (dispatch.workspaces.tiles), ops:
sync/resize/move/placeFirstEmpty/sortByRecentActivity.
- ui/HomeTileGrid.svelte — CSS-grid tile canvas (wide: horizontal scroll +
snap + wheel pan + pan arrow + scroll indicator; tall: native vertical
scroll), FLIP-animated reflow, drag ghost, transient resize preview.
- ui/HomeHeader.svelte — fixed chrome: Dispatch display title, New
workspace popover (same slug validation; creates via create-on-miss and
does NOT navigate — the new tile lands in the first empty slot in fill
order and enters rename mode), global Plugins button, sort-by-recent
overflow; compact icon buttons + bottom sheet on tall screens.
- ui/WorkspaceTile.svelte — tile face (title, /slug, conversation count,
relative last-activity, live active dots, starred tint); inline rename
with the old card's semantics.
- ui/TileActionBar.svelte — slides up on select (right-click / long-press):
Open, Rename, Resize, Star/Unstar, Details, Plugins, Delete (old confirm
+ store call preserved).
- ui/ResizePicker.svelte — Word-style insert-table hover grid (wide 4x2 /
tall 2x2) with arrow/Enter/Escape support and ghost preview on commit.
- ui/WorkspaceDetails.svelte — flyout holding the old card's default-cwd
input + default-computer select (same store calls + error states).
- WorkspacesHome.svelte orchestrates; App.svelte untouched (same props).
- WorkspaceCard.svelte is removed (replaced by the tile system).
Manual tile order rules; starred workspaces get a tint (star is no longer
a sort key); workspaces unknown to the layout append at the end.
vitest: jsdom gains PointerEvent (MouseEvent-based) + Element.animate
stubs — the pointer logic is the outermost edge for the drag tests.
Verification: svelte-check 0/0, vitest 1655 passed (102 files, twice),
biome clean, vite build OK.
Diffstat (limited to 'src/features/workspaces/logic/drag-controller.test.ts')
| -rw-r--r-- | src/features/workspaces/logic/drag-controller.test.ts | 173 |
1 files changed, 173 insertions, 0 deletions
diff --git a/src/features/workspaces/logic/drag-controller.test.ts b/src/features/workspaces/logic/drag-controller.test.ts new file mode 100644 index 0000000..d6dd760 --- /dev/null +++ b/src/features/workspaces/logic/drag-controller.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from "vitest"; +import { type DragPhase, type DragTransition, nextDragPhase } from "./drag-controller"; + +const OPTS = { dragThresholdPx: 6 }; + +function down(over: Partial<Parameters<typeof nextDragPhase>[1] & { type: "pointer-down" }> = {}) { + return { + type: "pointer-down" as const, + id: "t1", + x: 10, + y: 10, + touch: false, + ...over, + }; +} + +function press(touch = false): DragPhase { + return nextDragPhase({ kind: "idle" }, down({ touch }), OPTS).phase; +} + +describe("nextDragPhase — mouse", () => { + it("a pointer-down enters the undecided pressing state", () => { + expect(press()).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false }); + }); + + it("movement past the threshold starts a drag", () => { + const transition = nextDragPhase( + press(), + { type: "pointer-move", x: 20, y: 12, targetIndex: 3 }, + OPTS, + ); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 20, + y: 12, + targetIndex: 3, + touch: false, + }); + expect(transition.emit).toBeUndefined(); + }); + + it("movement within the threshold stays pressing", () => { + const transition = nextDragPhase( + press(), + { type: "pointer-move", x: 12, y: 14, targetIndex: 0 }, + OPTS, + ); + expect(transition.phase).toEqual({ kind: "pressing", id: "t1", x: 10, y: 10, touch: false }); + }); + + it("release without moving emits a tap (open the workspace)", () => { + const transition = nextDragPhase(press(), { type: "pointer-up" }, OPTS); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toEqual({ kind: "tap", id: "t1" }); + }); + + it("release while dragging emits a drop with the last tracked index", () => { + let transition: DragTransition = nextDragPhase( + press(), + { type: "pointer-move", x: 30, y: 30, targetIndex: 2 }, + OPTS, + ); + transition = nextDragPhase( + transition.phase, + { type: "pointer-move", x: 31, y: 31, targetIndex: 5 }, + OPTS, + ); + transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetIndex: 5 }); + expect(transition.phase).toEqual({ kind: "idle" }); + }); +}); + +describe("nextDragPhase — touch", () => { + it("a long-press lifts the tile and emits lift (arm the drag + select)", () => { + const transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + expect(transition.emit).toEqual({ kind: "lift", id: "t1" }); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 10, + y: 10, + targetIndex: -1, + touch: true, + }); + }); + + it("movement past the threshold BEFORE the lift cancels (vertical scrolling wins)", () => { + const transition = nextDragPhase( + press(true), + { type: "pointer-move", x: 10, y: 80, targetIndex: 0 }, + OPTS, + ); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toBeUndefined(); + }); + + it("movement within the threshold before the lift keeps pressing", () => { + const transition = nextDragPhase( + press(true), + { type: "pointer-move", x: 13, y: 12, targetIndex: 0 }, + OPTS, + ); + expect(transition.phase?.kind).toBe("pressing"); + }); + + it("after the lift, movement drags (never cancels)", () => { + let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + transition = nextDragPhase( + transition.phase, + { type: "pointer-move", x: 10, y: 90, targetIndex: 4 }, + OPTS, + ); + expect(transition.phase).toEqual({ + kind: "dragging", + id: "t1", + x: 10, + y: 90, + targetIndex: 4, + touch: true, + }); + }); + + it("a long-press never lifts a mouse press (desktop selects via right-click)", () => { + const transition = nextDragPhase(press(false), { type: "long-press" }, OPTS); + expect(transition.emit).toBeUndefined(); + expect(transition.phase?.kind).toBe("pressing"); + }); + + it("release while lifted (no movement) drops at the lifted tile's own slot", () => { + let transition = nextDragPhase(press(true), { type: "long-press" }, OPTS); + transition = nextDragPhase(transition.phase, { type: "pointer-up" }, OPTS); + expect(transition.emit).toEqual({ kind: "drop", id: "t1", targetIndex: -1 }); + }); +}); + +describe("nextDragPhase — cancellations", () => { + it("cancel returns to idle from any phase without emitting", () => { + for (const phase of [ + { kind: "idle" as const }, + press(), + nextDragPhase(press(), { type: "pointer-move", x: 40, y: 40, targetIndex: 1 }, OPTS).phase, + ]) { + const transition = nextDragPhase(phase, { type: "cancel" }, OPTS); + expect(transition.phase).toEqual({ kind: "idle" }); + expect(transition.emit).toBeUndefined(); + } + }); + + it("events in idle are inert", () => { + expect(nextDragPhase({ kind: "idle" }, { type: "pointer-up" }, OPTS).phase).toEqual({ + kind: "idle", + }); + expect( + nextDragPhase({ kind: "idle" }, { type: "pointer-move", x: 1, y: 1, targetIndex: 0 }, OPTS) + .phase, + ).toEqual({ kind: "idle" }); + expect(nextDragPhase({ kind: "idle" }, { type: "long-press" }, OPTS).phase).toEqual({ + kind: "idle", + }); + }); + + it("a second pointer-down restarts the gesture with the new tile", () => { + const dragging = nextDragPhase( + press(), + { type: "pointer-move", x: 40, y: 40, targetIndex: 1 }, + OPTS, + ).phase; + const transition = nextDragPhase(dragging, down({ id: "t2" }), OPTS); + expect(transition.phase).toEqual({ kind: "pressing", id: "t2", x: 10, y: 10, touch: false }); + }); +}); |
