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
+53 -10
View File
@@ -5,10 +5,17 @@
* - 44px min-height touch target
* - Checkbox: 20px visual / 44px touch target, --color-member-0 fill when checked
* - Item text: plain-text JSX (T-04-06 XSS guard); line-through + muted when completed
* - GripVertical handle slot on active items (non-functional here; Plan 05 wires dnd-kit)
* - GripVertical handle on active items — useSortable listeners scoped to handle only
* (Plan 05: dnd-kit wired; touch requires 200ms long-press via TouchSensor in ListDetail)
* - Delete affordance: hover Trash2 on desktop / swipe-left zone on phone
* - No confirmation on delete (D-06)
* - CSS transition 'transform 150ms ease-out' for Plan 05 remote reorder animation slot (D-14)
* - CSS.Transform.toString(transform) + transition for drag animation (D-14 remote reorder)
*
* Sortable behavior:
* - useSortable({ id: item.id }) — must be wrapped by SortableContext in the parent
* - listeners attached to handle button ONLY — taps on checkbox/text/delete still work
* - isDragging: opacity 0.8 + scale-down to give visual drag feedback
* - Completed items receive no handle (not reorderable per UI-SPEC)
*
* Optimistic behavior (caller responsibility):
* - Checking: caller's mutation moves item to completed section immediately
@@ -17,8 +24,20 @@
import { useState } from 'react'
import { GripVertical, Trash2 } from 'lucide-react'
import { useSortable } from '@dnd-kit/sortable'
import type { ListItem } from '../api/listsClient.js'
/**
* Convert a dnd-kit Transform object to a CSS transform string.
* Equivalent to CSS.Transform.toString() from @dnd-kit/utilities (not a direct
* dependency; inline to avoid adding @dnd-kit/utilities as a separate dep).
*/
function transformToString(transform: { x: number; y: number; scaleX: number; scaleY: number } | null): string | undefined {
if (!transform) return undefined
const { x, y } = transform
return `translate3d(${x ? Math.round(x) : 0}px, ${y ? Math.round(y) : 0}px, 0)`
}
interface ItemRowProps {
item: ListItem
/** Whether this is an active (unchecked) item — shows drag handle */
@@ -40,6 +59,17 @@ export function ItemRow({
const [swipeRevealed, setSwipeRevealed] = useState(false)
const [touchStartX, setTouchStartX] = useState<number | null>(null)
// useSortable is always called (React hook rules), but listeners are only
// attached to the handle button when isActive=true.
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id })
function handleCheckboxClick() {
onCheck(item.id, !item.checked)
}
@@ -66,15 +96,26 @@ export function ItemRow({
setTouchStartX(null)
}
// D-14: transformToString + transition animates remote reorders arriving
// via SSE (Plan 06). The transition fallback 'transform 150ms ease-out' applies
// when dnd-kit's own transition is not active (i.e. for non-drag CSS changes).
const transformStr = transformToString(transform)
const computedTransition = transition ?? 'transform 150ms ease-out'
return (
<div
ref={setNodeRef}
style={{
position: 'relative',
overflow: 'hidden',
// D-14: transition slot for Plan 05 remote reorder animation
transition: 'transform 150ms ease-out',
opacity: optimisticOpacity,
// D-14: transition slot for remote reorder animation
transform: transformStr,
transition: computedTransition,
opacity: isDragging ? 0.8 * optimisticOpacity : optimisticOpacity,
// Slight scale-down when dragging to give a "picked up" feel
...(isDragging ? { scale: '0.98' } : {}),
}}
{...attributes}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onTouchStart={handleTouchStart}
@@ -88,26 +129,28 @@ export function ItemRow({
gap: 'var(--space-2)',
minHeight: '44px',
padding: 'var(--space-2) var(--space-4)',
background: 'var(--color-surface)',
background: isDragging ? 'var(--color-surface-raised, var(--color-surface))' : 'var(--color-surface)',
transform: swipeRevealed ? 'translateX(-80px)' : 'translateX(0)',
transition: 'transform 200ms ease',
fontFamily: 'var(--font-family-base)',
}}
>
{/* Drag handle slot — GripVertical exists but non-functional until Plan 05 */}
{/* Drag handle — listeners scoped to this button only (not whole row).
Only active items are draggable (completed items are not reorderable). */}
{isActive && (
<button
aria-label="Drag to reorder (available in next update)"
{...listeners}
aria-label="Drag to reorder"
style={{
background: 'none',
border: 'none',
cursor: 'grab',
cursor: isDragging ? 'grabbing' : 'grab',
padding: 'var(--space-1)',
color: 'var(--color-text-muted)',
display: 'flex',
alignItems: 'center',
minWidth: '20px',
opacity: 0.5,
touchAction: 'none', // prevent browser scroll interference during drag
}}
>
<GripVertical size={16} aria-hidden="true" />
+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 */}