From 30819e2fc7e380c67ce79292656e2bc7af25b309 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 12:48:25 +0200 Subject: [PATCH] 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. --- .../components/application-card.tsx | 4 ++- .../applications/components/board.tsx | 34 +++++++++++++++++-- apps/web/src/libs/motion.ts | 3 ++ 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/apps/web/src/features/applications/components/application-card.tsx b/apps/web/src/features/applications/components/application-card.tsx index 1289e904f..5903eb60d 100644 --- a/apps/web/src/features/applications/components/application-card.tsx +++ b/apps/web/src/features/applications/components/application-card.tsx @@ -22,7 +22,9 @@ export function ApplicationCard({ application, onClick, withMenu = false, draggi
diff --git a/apps/web/src/features/applications/components/board.tsx b/apps/web/src/features/applications/components/board.tsx index 79be55eb9..7830b628c 100644 --- a/apps/web/src/features/applications/components/board.tsx +++ b/apps/web/src/features/applications/components/board.tsx @@ -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(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(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 ))}
- {/* No drop animation: the optimistic move lands after an await, so the default would fly the card back. */} - + {/* The overlay settles into the card's new slot (or back where it came from); the drop itself shows at once, see pendingMove. */} + {active ? : null} diff --git a/apps/web/src/libs/motion.ts b/apps/web/src/libs/motion.ts index a89a280c5..9684136da 100644 --- a/apps/web/src/libs/motion.ts +++ b/apps/web/src/libs/motion.ts @@ -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(", ")})` };