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(", ")})` };