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:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user