feat(web): land dropped kanban cards where they were dropped

The board now shows a dropped card in its target column in the same
render the drag ends, via a one-card override that clears once the list
query catches up or rolls back, so the card no longer blinks back to its
old column. The overlay then settles into the card's new slot (or back
home) with a 200ms on-screen-movement drop animation, skipped under
reduced motion. The floating card lifts in on pickup, flattens while it
lands, and shows a grabbing cursor.

Adds a shared DRAG_SETTLE dnd-kit timing to libs/motion.
This commit is contained in:
Amruth Pillai
2026-09-29 12:49:02 +02:00
parent 2a71d74e7f
commit 30819e2fc7
3 changed files with 37 additions and 4 deletions
@@ -22,7 +22,9 @@ export function ApplicationCard({ application, onClick, withMenu = false, draggi
<div
className={cn(
"group relative grid gap-2 rounded-[10px] border border-line bg-surface p-3 shadow-e1 transition-[border-color,scale] duration-quick ease-enter hover:border-line-2 active:not-has-[[data-slot=button]:active]:scale-[0.98]",
dragging && "rotate-1 shadow-e3",
// The overlay card lifts as it's picked up and settles flat again while the drop animation flies it home.
dragging &&
"in-[.is-dropping]:rotate-0 rotate-1 starting:rotate-0 in-[.is-dropping]:scale-100 scale-[1.02] starting:scale-100 cursor-grabbing in-[.is-dropping]:shadow-e1 shadow-e3 starting:shadow-e1 transition-[rotate,scale,box-shadow] duration-quick ease-enter **:cursor-grabbing",
)}
>
<div className="flex items-start gap-2.5">
@@ -4,6 +4,7 @@ import type { Application } from "../types";
import {
DndContext,
DragOverlay,
defaultDropAnimationSideEffects,
PointerSensor,
pointerWithin,
useDraggable,
@@ -12,8 +13,10 @@ import {
useSensors,
} from "@dnd-kit/core";
import { t } from "@lingui/core/macro";
import { useReducedMotion } from "motion/react";
import { useState } from "react";
import { cn } from "@reactive-resume/utils/style";
import { DRAG_SETTLE } from "@/libs/motion";
import { getStageColor, getStageLabel, PIPELINE } from "../stages";
import { useApplicationActions } from "../use-application-actions";
import { ApplicationCard } from "./application-card";
@@ -24,20 +27,43 @@ type BoardProps = {
onOpen: (application: Application) => void;
};
// While the overlay settles, the real card waits hidden in its new slot and the overlay's tilt eases off.
const dropSideEffects = defaultDropAnimationSideEffects({
styles: { active: { opacity: "0" } },
className: { dragOverlay: "is-dropping" },
});
/**
* A column per stage (Closed only when shown). Dropping a card on a column moves it, with the same toast as the
* other ways to change stage; each card's menu has Move to… for the keyboard. Desktop and tablet only.
*/
export function ApplicationBoard({ applications, showClosed, onOpen }: BoardProps) {
const { moveTo } = useApplicationActions();
const reduceMotion = useReducedMotion();
const [activeId, setActiveId] = useState<string | null>(null);
// The list query publishes the optimistic move a task after the drop (TanStack Query notifies on a timeout), so
// until it does the board shows the dropped card in its new column itself. Otherwise the card blinks back to its
// old column for a frame, and the drop animation would fly there.
const [pendingMove, setPendingMove] = useState<{ id: string; from: ApplicationStatus; to: ApplicationStatus } | null>(
null,
);
// The query caught up, rolled back, or the card went away: the override has done its job.
if (
pendingMove &&
applications.find((application) => application.id === pendingMove.id)?.status !== pendingMove.from
) {
setPendingMove(null);
}
const statusOf = (application: Application) =>
pendingMove?.id === application.id ? pendingMove.to : application.status;
// A small activation distance so a click still opens the detail sheet instead of starting a drag.
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
const stages: ApplicationStatus[] = showClosed ? [...PIPELINE, "closed"] : [...PIPELINE];
const byStage = new Map<ApplicationStatus, Application[]>(stages.map((stage) => [stage, []]));
for (const application of applications) byStage.get(application.status)?.push(application);
for (const application of applications) byStage.get(statusOf(application))?.push(application);
const active = activeId ? applications.find((application) => application.id === activeId) : null;
@@ -48,6 +74,8 @@ export function ApplicationBoard({ applications, showClosed, onOpen }: BoardProp
const target = event.over?.id as ApplicationStatus | undefined;
const application = applications.find((item) => item.id === event.active.id);
if (!target || !application || application.status === target) return;
// Same batched render as setActiveId(null): the card is already in its new column when the drop animation measures it.
setPendingMove({ id: application.id, from: application.status, to: target });
moveTo(application, target);
};
@@ -59,8 +87,8 @@ export function ApplicationBoard({ applications, showClosed, onOpen }: BoardProp
))}
</div>
{/* No drop animation: the optimistic move lands after an await, so the default would fly the card back. */}
<DragOverlay dropAnimation={null}>
{/* The overlay settles into the card's new slot (or back where it came from); the drop itself shows at once, see pendingMove. */}
<DragOverlay dropAnimation={reduceMotion ? null : { ...DRAG_SETTLE, sideEffects: dropSideEffects }}>
{active ? <ApplicationCard application={active} dragging /> : null}
</DragOverlay>
</DndContext>
+3
View File
@@ -16,3 +16,6 @@ export const EXIT = 0.7;
/** --ease-out-strong. Landing page only (features/homepage/site-footer.tsx); app code uses EASE. */
export const EASE_OUT_STRONG = [0.23, 1, 0.32, 1] as const;
/** dnd-kit timing for things settling into place (sortable shuffles, drop animations): on-screen movement. */
export const DRAG_SETTLE = { duration: D2 * 1000, easing: `cubic-bezier(${EASE_MOVE.join(", ")})` };