diff --git a/apps/pwa/src/components/ItemRow.tsx b/apps/pwa/src/components/ItemRow.tsx index 6eba398..fe4f1c6 100644 --- a/apps/pwa/src/components/ItemRow.tsx +++ b/apps/pwa/src/components/ItemRow.tsx @@ -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(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 (
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 && (