Skip to content

Commit a33244d

Browse files
committed
feat: add KanbanBoard component and enhance drag controls for reparenting - good attempt check point
1 parent 57cd36f commit a33244d

File tree

8 files changed

+296
-16
lines changed

8 files changed

+296
-16
lines changed
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
<script lang="ts">
2+
import { tick } from 'svelte';
3+
import type { PanInfo } from '$lib/motion-start';
4+
import { LayoutGroup, Reorder, useDragControls } from '$lib/motion-start';
5+
6+
type Column = 'todo' | 'inprogress' | 'done';
7+
type Card = { id: number; title: string };
8+
9+
let columns = $state<{ id: Column; label: string; cards: Card[] }[]>([
10+
{
11+
id: 'todo',
12+
label: 'Todo',
13+
cards: [
14+
{ id: 1, title: 'Design mockups' },
15+
{ id: 2, title: 'Write tests' },
16+
],
17+
},
18+
{
19+
id: 'inprogress',
20+
label: 'In Progress',
21+
cards: [
22+
{ id: 3, title: 'Implement feature' },
23+
{ id: 4, title: 'Code review' },
24+
],
25+
},
26+
{ id: 'done', label: 'Done', cards: [{ id: 5, title: 'Deploy to staging' }] },
27+
]);
28+
29+
// One DragControls per card — survives reparenting and carries the live session.
30+
const cardIds = [1, 2, 3, 4, 5];
31+
const controls = new Map(cardIds.map((id) => [id, useDragControls()]));
32+
function ctrl(id: number) {
33+
if (!controls.has(id)) controls.set(id, useDragControls());
34+
return controls.get(id)!;
35+
}
36+
37+
let draggingId = $state<number | null>(null);
38+
let isReparenting = false;
39+
40+
const cardEls: Record<number, HTMLElement | null> = {};
41+
42+
function getColumnFromPoint(x: number, y: number): Column | null {
43+
const els = document.elementsFromPoint(x, y);
44+
const el = els.find((e) => e.hasAttribute('data-column'));
45+
return (el?.getAttribute('data-column') as Column) ?? null;
46+
}
47+
48+
function getInsertIndex(colId: Column, y: number, excludeId: number): number {
49+
const col = columns.find((c) => c.id === colId)!;
50+
const cards = col.cards.filter((c) => c.id !== excludeId);
51+
for (let i = 0; i < cards.length; i++) {
52+
const el = cardEls[cards[i].id];
53+
if (!el) continue;
54+
const { top, height } = el.getBoundingClientRect();
55+
if (y < top + height / 2) return i;
56+
}
57+
return cards.length;
58+
}
59+
60+
async function handleDrag(card: Card, srcColId: Column, event: MouseEvent | TouchEvent | PointerEvent, _info: PanInfo) {
61+
if (isReparenting) return;
62+
63+
// getColumnFromPoint / getInsertIndex use document.elementsFromPoint + getBoundingClientRect
64+
// which operate in viewport (client) coordinates.
65+
const clientX = (event as PointerEvent).clientX;
66+
const clientY = (event as PointerEvent).clientY;
67+
68+
const targetCol = getColumnFromPoint(clientX, clientY);
69+
if (!targetCol || targetCol === srcColId) return;
70+
71+
isReparenting = true;
72+
73+
const src = columns.find((c) => c.id === srcColId)!;
74+
const dst = columns.find((c) => c.id === targetCol)!;
75+
const insertIdx = getInsertIndex(targetCol, clientY, card.id);
76+
src.cards = src.cards.filter((c) => c.id !== card.id);
77+
const newCards = [...dst.cards];
78+
newCards.splice(insertIdx, 0, card);
79+
dst.cards = newCards;
80+
81+
// Wait for old Reorder.Item to unmount (snapshot saved automatically) and
82+
// new one to mount (gesture resumed automatically via pendingResume).
83+
await tick();
84+
85+
isReparenting = false;
86+
}
87+
88+
function handleDragStart(card: Card) {
89+
draggingId = card.id;
90+
}
91+
92+
function handleDragEnd() {
93+
draggingId = null;
94+
isReparenting = false;
95+
}
96+
</script>
97+
98+
<LayoutGroup>
99+
<div class="flex flex-row gap-3 p-4 bg-gray-800 rounded-xl w-full overflow-x-auto">
100+
{#each columns as col (col.id)}
101+
<div
102+
data-column={col.id}
103+
class="rounded-lg p-3 flex flex-col gap-2 min-h-48 w-44 shrink-0 bg-gray-700"
104+
>
105+
<h3 class="text-xs font-semibold uppercase tracking-wide text-gray-400 mb-1">
106+
{col.label}
107+
<span class="ml-1 text-gray-500">({col.cards.length})</span>
108+
</h3>
109+
110+
<Reorder.Group
111+
as="div"
112+
axis="y"
113+
values={col.cards}
114+
onReorder={(v) => { col.cards = v; }}
115+
class="flex flex-col gap-2 min-h-6"
116+
>
117+
{#snippet children({ item: card })}
118+
<Reorder.Item
119+
as="div"
120+
value={card}
121+
layoutId="card-{card.id}"
122+
layout={true}
123+
drag={true}
124+
dragControls={ctrl(card.id)}
125+
dragListener={false}
126+
whileDrag={{ zIndex: 50, cursor: 'grabbing' }}
127+
animate={draggingId === card.id ? { rotate: 3 } : { rotate: 0 }}
128+
onDragStart={() => handleDragStart(card)}
129+
onDrag={(e, info) => handleDrag(card, col.id, e, info)}
130+
onDragEnd={() => handleDragEnd()}
131+
class="bg-gray-600 rounded-md p-2 select-none flex items-center gap-2 text-sm text-white"
132+
ref={(el) => { cardEls[card.id] = el as HTMLElement | null; }}
133+
>
134+
<div
135+
class="cursor-grab text-gray-400 hover:text-white shrink-0 touch-none"
136+
role="button"
137+
tabindex="-1"
138+
onpointerdown={(e) => { e.preventDefault(); draggingId = card.id; ctrl(card.id).start(e); }}
139+
>
140+
<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor" aria-hidden="true">
141+
<circle cx="2.5" cy="2" r="1.5" />
142+
<circle cx="7.5" cy="2" r="1.5" />
143+
<circle cx="2.5" cy="6" r="1.5" />
144+
<circle cx="7.5" cy="6" r="1.5" />
145+
<circle cx="2.5" cy="10" r="1.5" />
146+
<circle cx="7.5" cy="10" r="1.5" />
147+
</svg>
148+
</div>
149+
<span class="truncate leading-snug">{card.title}</span>
150+
</Reorder.Item>
151+
{/snippet}
152+
</Reorder.Group>
153+
</div>
154+
{/each}
155+
</div>
156+
</LayoutGroup>

src/lib/components/motion/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// @ts-nocheck
22
export { default as AnimateLayout } from './AnimateLayout.svelte';
3+
export { default as AnimatePresenceMode } from './AnimatePresenceMode.svelte';
34
export { default as AnimatePresenceStack } from './AnimatePresenceStack.svelte';
45
export { default as AnimationSequence } from './AnimationSequence.svelte';
56
export { default as ColorInterpolation } from './ColorInterpolation.svelte';
@@ -9,16 +10,16 @@ export { default as DragConstraints } from './DragConstraints.svelte';
910
export { default as DragDirectionLocking } from './DragDirectionLocking.svelte';
1011
export { default as DragTransform } from './DragTransform.svelte';
1112
export { default as DurBasedSpring } from './DurBasedSpring.svelte';
13+
export { default as KanbanBoard } from './KanbanBoard.svelte';
1214
export { default as KeyFramesPosition } from './KeyFramesPosition.svelte';
1315
export { default as MorphSvg } from './MorphSVG.svelte';
1416
export { default as Navbar } from './Navbar.svelte';
1517
export { default as Repeat } from './Repeat.svelte';
18+
export { default as ReorderList } from './ReorderList/Index.svelte';
1619
export { default as ReverseEffect } from './ReverseEffect.svelte';
1720
export { default as ScrollProgress } from './ScrollProgress.svelte';
1821
export { default as Spring } from './Spring.svelte';
1922
export { default as Tweened } from './Tweened.svelte';
2023
export { default as WhileDragEffect } from './WhileDragEffect.svelte';
2124
export { default as WhileHoverEffect } from './WhileHoverEffect.svelte';
2225
export { default as WhileTapEffect } from './WhileTapEffect.svelte';
23-
export { default as AnimatePresenceMode } from './AnimatePresenceMode.svelte';
24-
export { default as ReorderList } from './ReorderList/Index.svelte';

src/lib/motion-start/components/Reorder/Item.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ Copyright (c) 2018 Framer B.V. -->
5757
value,
5858
onDrag,
5959
layout = true,
60+
drag: dragProp,
6061
ref: externalRef = $bindable(),
6162
...props
6263
}: Props<V> &
@@ -89,7 +90,7 @@ Copyright (c) 2018 Framer B.V. -->
8990
</script>
9091

9192
<ReorderItem
92-
drag={axis}
93+
drag={dragProp ?? axis}
9394
{...props}
9495
dragSnapToOrigin
9596
style={{

src/lib/motion-start/gestures/drag/VisualElementDragControls.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ export class VisualElementDragControls {
6565

6666
private originPoint: Point = { x: 0, y: 0 };
6767

68+
/**
69+
* The cursor's position within the element's bounding box, as a 0-1 progress
70+
* value on each axis. Updated every move. Saved on unmount so the next element
71+
* with the same dragControls can resume the gesture at the same relative position.
72+
*/
73+
cursorProgress: Point = { x: 0.5, y: 0.5 };
74+
75+
/**
76+
* The last pointer event seen during this drag. Saved so reparenting can
77+
* resume using the same event origin.
78+
*/
79+
lastPointerEvent: PointerEvent | null = null;
80+
6881
/**
6982
* Shift the drag origin point by `delta` on the given axis.
7083
* Called by Reorder.Group when a slot swap happens so that the next
@@ -87,7 +100,7 @@ export class VisualElementDragControls {
87100
this.visualElement = visualElement;
88101
}
89102

90-
start(originEvent: PointerEvent, { snapToCursor = false }: DragControlOptions = {}) {
103+
start(originEvent: PointerEvent, { snapToCursor = false, cursorProgress }: DragControlOptions = {}) {
91104
/**
92105
* Don't start dragging if this component is exiting
93106
*/
@@ -133,6 +146,22 @@ export class VisualElementDragControls {
133146
* Record gesture origin
134147
*/
135148
eachAxis((axis) => {
149+
/**
150+
* If cursorProgress is provided (reparent resume), compute origin so
151+
* the element sits under the cursor at the same relative position.
152+
*/
153+
if (cursorProgress !== undefined) {
154+
const { projection } = this.visualElement;
155+
if (projection && projection.layout) {
156+
const { min, max } = projection.layout.layoutBox[axis];
157+
const elementSize = max - min;
158+
const cursorPage = extractEventInfo(originEvent, 'page').point[axis];
159+
// originPoint = cursorPage - cursorProgress * elementSize - min
160+
this.originPoint[axis] = cursorPage - cursorProgress[axis] * elementSize - min;
161+
}
162+
return;
163+
}
164+
136165
let current = this.getAxisMotionValue(axis).get() || 0;
137166

138167
/**
@@ -166,7 +195,20 @@ export class VisualElementDragControls {
166195
};
167196

168197
const onMove = (event: PointerEvent, info: PanInfo) => {
169-
// latestPointerEvent = event
198+
this.lastPointerEvent = event;
199+
200+
// Update cursorProgress (cursor position within element, 0-1 on each axis)
201+
const { projection } = this.visualElement;
202+
const projectionLayout = projection?.layout;
203+
if (projectionLayout) {
204+
eachAxis((axis) => {
205+
const { min, max } = projectionLayout.layoutBox[axis];
206+
const elementSize = max - min;
207+
if (elementSize > 0) {
208+
this.cursorProgress[axis] = (info.point[axis] - min) / elementSize;
209+
}
210+
});
211+
}
170212

171213
const { dragPropagation, dragDirectionLock, onDirectionLock, onDrag } = this.getProps();
172214

@@ -242,6 +284,22 @@ export class VisualElementDragControls {
242284
}
243285
}
244286

287+
/**
288+
* End the active PanSession and release the global drag lock without
289+
* triggering the full cancel() path (no whileDrag reset, no stop animation).
290+
* Used on reparent unmount so the old window listeners are removed and the
291+
* lock is free before the new element's start() acquires it.
292+
*/
293+
endPanSession() {
294+
this.panSession?.end();
295+
this.panSession = undefined;
296+
const { dragPropagation } = this.getProps();
297+
if (!dragPropagation && this.openGlobalLock) {
298+
this.openGlobalLock();
299+
this.openGlobalLock = null;
300+
}
301+
}
302+
245303
private cancel() {
246304
this.isDragging = false;
247305
const { projection, animationState } = this.visualElement;

src/lib/motion-start/gestures/drag/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,34 @@ export class DragGesture extends Feature<HTMLElement> {
3535
});
3636

3737
this.removeListeners = this.controls.addListeners() || noop;
38+
39+
// If a reparent snapshot is pending, resume the drag gesture on this new element.
40+
if (dragControls?.pendingResume) {
41+
const { lastPointerEvent, cursorProgress } = dragControls.pendingResume;
42+
dragControls.pendingResume = null;
43+
// Defer until after projection layout is measured (next microtask).
44+
Promise.resolve().then(() => {
45+
this.controls.start(lastPointerEvent, { cursorProgress });
46+
});
47+
}
3848
}
3949

4050
unmount() {
51+
// If this element is currently being dragged, save a snapshot so the next
52+
// element with the same DragControls can resume the gesture (reparenting).
53+
const { dragControls } = this.node.getProps();
54+
if (dragControls && this.controls.isDragging && this.controls.lastPointerEvent) {
55+
dragControls.pendingResume = {
56+
lastPointerEvent: this.controls.lastPointerEvent,
57+
cursorProgress: { ...this.controls.cursorProgress },
58+
};
59+
// End the PanSession so its window listeners don't conflict with the
60+
// new element's PanSession, but don't call cancel() so isDragging stays
61+
// true (snapshot was just saved above) and the global drag lock is kept
62+
// until the new element's start() acquires it.
63+
this.controls.endPanSession();
64+
}
65+
4166
this.removeGroupControls();
4267
this.removeListeners();
4368
super.unmount();

src/lib/motion-start/gestures/drag/use-drag-controls.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1-
/**
1+
/**
22
based on framer-motion@11.11.11,
33
Copyright (c) 2018 Framer B.V.
44
*/
55

66
import type { VisualElementDragControls, DragControlOptions } from './VisualElementDragControls';
7+
8+
export interface DragResumeSnapshot {
9+
lastPointerEvent: PointerEvent;
10+
cursorProgress: { x: number; y: number };
11+
}
12+
713
/**
814
* Can manually trigger a drag gesture on one or more `drag`-enabled `motion` components.
915
*
@@ -24,6 +30,16 @@ import type { VisualElementDragControls, DragControlOptions } from './VisualElem
2430
*/
2531
export class DragControls {
2632
private componentControls = new Set<VisualElementDragControls>();
33+
34+
/**
35+
* Snapshot of an interrupted drag gesture, set by DragGesture.unmount() when
36+
* a dragging element is reparented. The next DragGesture.mount() with this
37+
* same DragControls reads it and resumes the gesture.
38+
*
39+
* @internal
40+
*/
41+
pendingResume: DragResumeSnapshot | null = null;
42+
2743
/**
2844
* Subscribe a component's internal `VisualElementDragControls` to the user-facing API.
2945
*
@@ -34,6 +50,7 @@ export class DragControls {
3450

3551
return () => this.componentControls.delete(controls);
3652
};
53+
3754
/**
3855
* Start a drag gesture on every `motion` component that has this set of drag controls
3956
* passed into it via the `dragControls` prop.

0 commit comments

Comments
 (0)