/** * ItemRow — a single list item row with checkbox, text, drag handle, and delete. * * Design contract (UI-SPEC §ItemRow): * - 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 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.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 * - Delete: caller removes item from cache; no rollback (D-09) */ 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 */ isActive: boolean; onCheck: (itemId: number, checked: boolean) => void; onDelete: (itemId: number) => void; /** Opacity for optimistic pending state (e.g. 0.6 while add is confirming) */ optimisticOpacity?: number; } export function ItemRow({ item, isActive, onCheck, onDelete, optimisticOpacity = 1, }: ItemRowProps) { const [hovered, setHovered] = useState(false); 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); } function handleDelete() { setSwipeRevealed(false); onDelete(item.id); } function handleTouchStart(e: React.TouchEvent) { setTouchStartX(e.touches[0].clientX); } function handleTouchEnd(e: React.TouchEvent) { if (touchStartX === null) return; const deltaX = touchStartX - e.changedTouches[0].clientX; if (deltaX > 60) { // Swipe-left: reveal delete zone setSwipeRevealed(true); } else if (deltaX < -20) { // Swipe-right: hide delete zone setSwipeRevealed(false); } 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} onTouchEnd={handleTouchEnd} > {/* Main row */}
{/* Drag handle — listeners scoped to this button only (not whole row). Only active items are draggable (completed items are not reorderable). */} {isActive && ( )} {/* Checkbox (44px touch area, 20px visual) */} {/* Item text — plain text only (T-04-06 XSS guard: no dangerouslySetInnerHTML) */} {item.text} {/* Desktop delete button — visible on hover */} {hovered && ( )}
{/* Swipe-left delete zone (phone) */} {swipeRevealed && ( )}
); }