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