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" />