Use @async/flow for the discrete lifecycle and drop rules. Keep high-frequency
pointer coordinates in plain signals or DOM-local state.
import {
can,
compose,
every,
flow,
guard,
matches,
not,
set,
status
} from "@async/flow";
const cards = {
async move({ cardId, fromColumnId, toColumnId }) {
return { cardId, fromColumnId, toColumnId };
}
};
const clearDrag = set({
phase: "idle",
cardId: null,
fromColumnId: null,
overColumnId: null
});
export const cardDrag = flow(
{
context() {
return { cards };
}
},
{
store: {
phase: status("idle", ["idle", "dragging", "overTarget", "dropping"]),
cardId: null,
fromColumnId: null,
overColumnId: null,
dragging: matches("phase", ["dragging", "overTarget"]),
overTarget: matches("phase", "overTarget"),
dropReady: every(
matches("phase", "overTarget"),
(store) => store.cardId,
(store) => store.fromColumnId,
(store) => store.overColumnId
),
canDrop: can("drop"),
dropBlocked: not(can("drop"))
},
on: {
start: guard(
(_store, input) => Boolean(input?.cardId && input?.columnId),
set({
phase: "dragging",
cardId: (_store, input) => input.cardId,
fromColumnId: (_store, input) => input.columnId,
overColumnId: null
}),
{ reason: "missing_drag_source", label: "Start card drag" }
),
over: guard(
(store, input) =>
store.phase !== "idle" &&
Boolean(input?.columnId) &&
store.fromColumnId !== input.columnId,
set({
phase: "overTarget",
overColumnId: (_store, input) => input.columnId
}),
{ reason: "same_column", label: "Choose another column" }
),
leave: guard(
(store) => store.phase === "overTarget",
set({
phase: "dragging",
overColumnId: null
})
),
drop: guard(
every(
matches("phase", "overTarget"),
(store) => store.cardId,
(store) => store.fromColumnId,
(store) => store.overColumnId
),
compose([
set("phase", "dropping"),
async function moveCard(store) {
await this.cards.move({
cardId: store.cardId,
fromColumnId: store.fromColumnId,
toColumnId: store.overColumnId
});
},
clearDrag
]),
{ reason: "no_drop_target", label: "Drop card" }
),
cancel: clearDrag
}
}
);Flat public reads stay value-like:
cardDrag.phase; // "idle" | "dragging" | "overTarget" | "dropping"
cardDrag.dropReady; // boolean
cardDrag.subscribe("dropReady", (ready) => {
updateDropIndicator(ready);
});Mounted under the name cardDrag, Async markup can pass dataset input directly:
<article
draggable="true"
data-card-id="card-1"
data-column-id="todo"
on:dragstart="cardDrag.start($dataset)"
on:dragend="cardDrag.cancel"
class:dragging="cardDrag.dragging"
>
Card title
</article>
<section
data-column-id="done"
on:dragover="preventDefault; cardDrag.over($dataset)"
on:dragleave="cardDrag.leave"
on:drop="preventDefault; cardDrag.drop"
class:drop-target="cardDrag.overTarget"
class:drop-ready="cardDrag.dropReady"
>
Done
</section>Lifecycle:
idle -> dragging -> overTarget -> dropping -> idleFlow owns the named events, store writes, guards, and published store values. The DOM owns drag browser events and visual bindings.