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
|
* - 44px min-height touch target
|
||||||
* - Checkbox: 20px visual / 44px touch target, --color-member-0 fill when checked
|
* - 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
|
* - 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
|
* - Delete affordance: hover Trash2 on desktop / swipe-left zone on phone
|
||||||
* - No confirmation on delete (D-06)
|
* - 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):
|
* Optimistic behavior (caller responsibility):
|
||||||
* - Checking: caller's mutation moves item to completed section immediately
|
* - Checking: caller's mutation moves item to completed section immediately
|
||||||
@@ -17,8 +24,20 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { GripVertical, Trash2 } from 'lucide-react'
|
import { GripVertical, Trash2 } from 'lucide-react'
|
||||||
|
import { useSortable } from '@dnd-kit/sortable'
|
||||||
import type { ListItem } from '../api/listsClient.js'
|
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 {
|
interface ItemRowProps {
|
||||||
item: ListItem
|
item: ListItem
|
||||||
/** Whether this is an active (unchecked) item — shows drag handle */
|
/** Whether this is an active (unchecked) item — shows drag handle */
|
||||||
@@ -40,6 +59,17 @@ export function ItemRow({
|
|||||||
const [swipeRevealed, setSwipeRevealed] = useState(false)
|
const [swipeRevealed, setSwipeRevealed] = useState(false)
|
||||||
const [touchStartX, setTouchStartX] = useState<number | null>(null)
|
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() {
|
function handleCheckboxClick() {
|
||||||
onCheck(item.id, !item.checked)
|
onCheck(item.id, !item.checked)
|
||||||
}
|
}
|
||||||
@@ -66,15 +96,26 @@ export function ItemRow({
|
|||||||
setTouchStartX(null)
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
// D-14: transition slot for Plan 05 remote reorder animation
|
// D-14: transition slot for remote reorder animation
|
||||||
transition: 'transform 150ms ease-out',
|
transform: transformStr,
|
||||||
opacity: optimisticOpacity,
|
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)}
|
onMouseEnter={() => setHovered(true)}
|
||||||
onMouseLeave={() => setHovered(false)}
|
onMouseLeave={() => setHovered(false)}
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart}
|
||||||
@@ -88,26 +129,28 @@ export function ItemRow({
|
|||||||
gap: 'var(--space-2)',
|
gap: 'var(--space-2)',
|
||||||
minHeight: '44px',
|
minHeight: '44px',
|
||||||
padding: 'var(--space-2) var(--space-4)',
|
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)',
|
transform: swipeRevealed ? 'translateX(-80px)' : 'translateX(0)',
|
||||||
transition: 'transform 200ms ease',
|
transition: 'transform 200ms ease',
|
||||||
fontFamily: 'var(--font-family-base)',
|
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 && (
|
{isActive && (
|
||||||
<button
|
<button
|
||||||
aria-label="Drag to reorder (available in next update)"
|
{...listeners}
|
||||||
|
aria-label="Drag to reorder"
|
||||||
style={{
|
style={{
|
||||||
background: 'none',
|
background: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
cursor: 'grab',
|
cursor: isDragging ? 'grabbing' : 'grab',
|
||||||
padding: 'var(--space-1)',
|
padding: 'var(--space-1)',
|
||||||
color: 'var(--color-text-muted)',
|
color: 'var(--color-text-muted)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
minWidth: '20px',
|
minWidth: '20px',
|
||||||
opacity: 0.5,
|
touchAction: 'none', // prevent browser scroll interference during drag
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<GripVertical size={16} aria-hidden="true" />
|
<GripVertical size={16} aria-hidden="true" />
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* ListDetail — Single list view (/lists/:listId).
|
* 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)
|
* - 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)
|
* - Splits items into active (!checked, sorted rank ASC) and completed (checked) (D-05)
|
||||||
* - Optimistic mutations for add / check / delete (D-07/D-09)
|
* - Optimistic mutations for add / check / delete (D-07/D-09)
|
||||||
* - Delete-wins: no rollback on item delete (D-09)
|
* - Delete-wins: no rollback on item delete (D-09)
|
||||||
* - Per-field LWW PATCH: check/uncheck sends { checked } only (D-08)
|
* - 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.
|
* 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).
|
* 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 { useParams, useNavigate } from 'react-router'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { ChevronLeft } from 'lucide-react'
|
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 {
|
import {
|
||||||
fetchListItems,
|
fetchListItems,
|
||||||
addItem,
|
addItem,
|
||||||
@@ -77,6 +98,24 @@ export function ListDetail() {
|
|||||||
|
|
||||||
const [completedExpanded, setCompletedExpanded] = useState(true)
|
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 ─────────────────────────────────────────────────────────────
|
// ── Data fetch ─────────────────────────────────────────────────────────────
|
||||||
// refetchInterval: 30000 = D-12 polling fallback (always active; SSE layered in Plan 06)
|
// refetchInterval: 30000 = D-12 polling fallback (always active; SSE layered in Plan 06)
|
||||||
const { data, isLoading, isError } = useQuery({
|
const { data, isLoading, isError } = useQuery({
|
||||||
@@ -102,7 +141,6 @@ export function ListDetail() {
|
|||||||
|
|
||||||
// Compute optimistic rank: after the last active item
|
// Compute optimistic rank: after the last active item
|
||||||
const lastRank = activeItems.at(-1)?.rank ?? null
|
const lastRank = activeItems.at(-1)?.rank ?? null
|
||||||
const { generateKeyBetween } = await import('fractional-indexing')
|
|
||||||
const optimisticRank = generateKeyBetween(lastRank, null)
|
const optimisticRank = generateKeyBetween(lastRank, null)
|
||||||
|
|
||||||
const optimisticItem: ListItem = {
|
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 ───────────────────────────────────────────────────────────────
|
// ── Handlers ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function handleAdd(text: string) {
|
function handleAdd(text: string) {
|
||||||
@@ -186,6 +252,47 @@ export function ListDetail() {
|
|||||||
deleteMutation.mutate(itemId)
|
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 ─────────────────────────────────────────────────
|
// ── Loading / error states ─────────────────────────────────────────────────
|
||||||
|
|
||||||
if (isNaN(parsedListId)) {
|
if (isNaN(parsedListId)) {
|
||||||
@@ -295,22 +402,33 @@ export function ListDetail() {
|
|||||||
|
|
||||||
{!isLoading && !isError && allItems.length === 0 && <ListEmptyState />}
|
{!isLoading && !isError && allItems.length === 0 && <ListEmptyState />}
|
||||||
|
|
||||||
{/* Active items section */}
|
{/* Active items section — wrapped in DndContext + SortableContext (LIST-03) */}
|
||||||
{activeItems.length > 0 && (
|
{activeItems.length > 0 && (
|
||||||
<div role="list" aria-label="Active items">
|
<DndContext
|
||||||
{activeItems.map((item) => (
|
sensors={sensors}
|
||||||
<div key={item.id} role="listitem">
|
collisionDetection={closestCenter}
|
||||||
<ItemRow
|
onDragEnd={handleDragEnd}
|
||||||
item={item}
|
>
|
||||||
isActive={true}
|
<SortableContext
|
||||||
onCheck={handleCheck}
|
items={activeItems.map((i) => i.id)}
|
||||||
onDelete={handleDelete}
|
strategy={verticalListSortingStrategy}
|
||||||
// Newly optimistic items have negative id and dim opacity
|
>
|
||||||
optimisticOpacity={item.id < 0 ? 0.6 : 1}
|
<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>
|
||||||
</div>
|
</DndContext>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Completed section (D-05) — collapsible, default expanded */}
|
{/* Completed section (D-05) — collapsible, default expanded */}
|
||||||
|
|||||||
Reference in New Issue
Block a user