feat(04-05): dnd-kit drag-to-reorder active items (LIST-03, D-13/D-14/D-15)

- ItemRow: useSortable with drag listeners scoped to GripVertical handle only;
  CSS transform animation for remote reorders (D-14); grabbing cursor when dragging
- ListDetail: DndContext/SortableContext over active items; PointerSensor (immediate),
  TouchSensor (200ms delay + 5px tolerance — no accidental scroll drags),
  KeyboardSensor (accessibility fallback)
- onDragEnd: computes generateKeyBetween(prevRank, nextRank) at destination, fires
  optimistic setQueryData then PATCHes { position: newRank } — one-row write (D-13)
- Rollback on PATCH error restores previous order via onError (D-15 LWW convergence)
- Completed items receive no drag handle (not reorderable per UI-SPEC)
This commit is contained in:
Lucas Berger
2026-06-09 13:13:11 -04:00
parent 8ed105d467
commit d49c5f1c9c
2 changed files with 188 additions and 27 deletions
+135 -17
View File
@@ -1,15 +1,20 @@
/**
* ListDetail — Single list view (/lists/:listId).
*
* Replaces the Plan 04-01 placeholder. Delivers LIST-02:
* Replaces the Plan 04-01 placeholder. Delivers LIST-02 + LIST-03:
* - Fetches items via useQuery(['list', listId]) with 30s polling fallback (D-12)
* - Splits items into active (!checked, sorted rank ASC) and completed (checked) (D-05)
* - Optimistic mutations for add / check / delete (D-07/D-09)
* - Delete-wins: no rollback on item delete (D-09)
* - Per-field LWW PATCH: check/uncheck sends { checked } only (D-08)
* - Drag-to-reorder (dnd-kit): DndContext + SortableContext over active items (LIST-03, D-13)
* - onDragEnd: computes generateKeyBetween, PATCHes { position } optimistically, rolls back on error
* - TouchSensor with 200ms delay + 5px tolerance — no accidental scroll drags
* - KeyboardSensor for accessibility keyboard-reorder fallback
* - Remote reorders animate via CSS.Transform.toString in ItemRow (D-14)
* - Concurrent reorder converges via server last-write-wins (D-15)
*
* Live sync (SSE) is wired in Plan 06.
* Drag-to-reorder (dnd-kit) is wired in Plan 05.
*
* Security: item text rendered as plain-text JSX children (T-04-06 XSS guard).
*/
@@ -18,6 +23,22 @@ import { useState } from 'react'
import { useParams, useNavigate } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { ChevronLeft } from 'lucide-react'
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
TouchSensor,
useSensors,
useSensor,
type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { generateKeyBetween } from 'fractional-indexing'
import {
fetchListItems,
addItem,
@@ -77,6 +98,24 @@ export function ListDetail() {
const [completedExpanded, setCompletedExpanded] = useState(true)
// ── dnd-kit sensors ────────────────────────────────────────────────────────
// PointerSensor: desktop mouse — immediate activation
// TouchSensor: mobile touch — 200ms long-press + 5px tolerance to avoid
// accidental drags while scrolling (no accidental drags constraint)
// KeyboardSensor: accessibility keyboard-reorder fallback (WCAG)
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(TouchSensor, {
activationConstraint: {
delay: 200,
tolerance: 5,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
// ── Data fetch ─────────────────────────────────────────────────────────────
// refetchInterval: 30000 = D-12 polling fallback (always active; SSE layered in Plan 06)
const { data, isLoading, isError } = useQuery({
@@ -102,7 +141,6 @@ export function ListDetail() {
// Compute optimistic rank: after the last active item
const lastRank = activeItems.at(-1)?.rank ?? null
const { generateKeyBetween } = await import('fractional-indexing')
const optimisticRank = generateKeyBetween(lastRank, null)
const optimisticItem: ListItem = {
@@ -172,6 +210,34 @@ export function ListDetail() {
},
})
// ── Reorder mutation (D-13 single-row rank write, D-15 LWW convergence) ──
const reorderMutation = useMutation({
mutationFn: ({ itemId, position }: { itemId: number; position: string }) =>
patchListItem(itemId, { position }),
onMutate: async ({ itemId, position }) => {
await queryClient.cancelQueries({ queryKey: ['list', parsedListId] })
const previous = queryClient.getQueryData<ListItemsResponse>(['list', parsedListId])
// Optimistic: update the moved item's rank in cache immediately
queryClient.setQueryData<ListItemsResponse>(['list', parsedListId], (old) => ({
items: (old?.items ?? []).map((item) =>
item.id === itemId ? { ...item, rank: position } : item,
),
}))
return { previous }
},
onError: (_err, _vars, context) => {
// Rollback: animate back to previous order (D-14 — CSS transition in ItemRow handles animation)
if (context?.previous) {
queryClient.setQueryData(['list', parsedListId], context.previous)
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['list', parsedListId] })
},
})
// ── Handlers ───────────────────────────────────────────────────────────────
function handleAdd(text: string) {
@@ -186,6 +252,47 @@ export function ListDetail() {
deleteMutation.mutate(itemId)
}
/**
* onDragEnd: compute new fractional rank and issue a single-item PATCH (D-13).
*
* Algorithm:
* 1. Find the dragged item's current position in activeItems.
* 2. Find the target position (over.id).
* 3. Derive prevRank/nextRank from the active list at the destination.
* 4. Compute newRank = generateKeyBetween(prevRank, nextRank).
* 5. Optimistically update cache, then PATCH { position: newRank }.
* Only the moved item's rank is written (one-row PATCH — D-13).
*
* Concurrent reorder of the same item from another member: server LWW on
* updatedAt resolves to the last write — both clients converge within ~1s
* via SSE (Plan 06) or the 30s polling fallback (D-12, D-15).
*/
function handleDragEnd(event: DragEndEvent) {
const { active, over } = event
// No-op: dropped outside or on itself
if (!over || active.id === over.id) return
// Find source and destination indices in the sorted active list
const activeIndex = activeItems.findIndex((i) => i.id === active.id)
const overIndex = activeItems.findIndex((i) => i.id === over.id)
if (activeIndex === -1 || overIndex === -1) return
// Build the new order by moving the item to the target position
const newOrder = [...activeItems]
const [moved] = newOrder.splice(activeIndex, 1)
newOrder.splice(overIndex, 0, moved)
// Derive prevRank/nextRank from the reordered list at the moved item's new position
const prevRank = newOrder[overIndex - 1]?.rank ?? null
const nextRank = newOrder[overIndex + 1]?.rank ?? null
const newRank = generateKeyBetween(prevRank, nextRank)
reorderMutation.mutate({ itemId: Number(active.id), position: newRank })
}
// ── Loading / error states ─────────────────────────────────────────────────
if (isNaN(parsedListId)) {
@@ -295,22 +402,33 @@ export function ListDetail() {
{!isLoading && !isError && allItems.length === 0 && <ListEmptyState />}
{/* Active items section */}
{/* Active items section — wrapped in DndContext + SortableContext (LIST-03) */}
{activeItems.length > 0 && (
<div role="list" aria-label="Active items">
{activeItems.map((item) => (
<div key={item.id} role="listitem">
<ItemRow
item={item}
isActive={true}
onCheck={handleCheck}
onDelete={handleDelete}
// Newly optimistic items have negative id and dim opacity
optimisticOpacity={item.id < 0 ? 0.6 : 1}
/>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={activeItems.map((i) => i.id)}
strategy={verticalListSortingStrategy}
>
<div role="list" aria-label="Active items">
{activeItems.map((item) => (
<div key={item.id} role="listitem">
<ItemRow
item={item}
isActive={true}
onCheck={handleCheck}
onDelete={handleDelete}
// Newly optimistic items have negative id and dim opacity
optimisticOpacity={item.id < 0 ? 0.6 : 1}
/>
</div>
))}
</div>
))}
</div>
</SortableContext>
</DndContext>
)}
{/* Completed section (D-05) — collapsible, default expanded */}