feat(04-04): add ListDetail with active/completed split + ItemRow + AddItemInput (LIST-02)

- listsClient.ts: add fetchListItems, addItem, patchListItem, deleteItem + ListItemsResponse type
- ListDetail.tsx: replace placeholder with real implementation — useQuery(['list', listId])
  with 30s polling fallback (D-12); active/completed split (D-05); optimistic mutations (D-07);
  delete-wins no-rollback (D-09); per-field check PATCH (D-08)
- ItemRow.tsx: 44px touch target, checkbox (20px visual/44px touch, accent fill when checked),
  plain-text item text (T-04-06 XSS guard), GripVertical handle slot for Plan 05,
  hover Trash2 delete + swipe-left zone, transform 150ms ease-out animation slot (D-14)
- AddItemInput.tsx: sticky bottom input + Add button, disabled when empty, Enter key support
- ListDetail.test.tsx: 7 real tests replacing todo stubs — optimistic add/check/uncheck/delete,
  rollback on error, D-05 completed-sink split, D-09 delete-wins no-rollback
- Playwright browser check: add milk → sinks to Completed on check → vanishes on delete PASS
This commit is contained in:
Lucas Berger
2026-06-09 13:01:49 -04:00
parent 5e3151416c
commit 6da9c2ae7b
5 changed files with 934 additions and 27 deletions
+230
View File
@@ -0,0 +1,230 @@
/**
* 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 slot on active items (non-functional here; Plan 05 wires dnd-kit)
* - 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)
*
* 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 type { ListItem } from '../api/listsClient.js'
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<number | null>(null)
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)
}
return (
<div
style={{
position: 'relative',
overflow: 'hidden',
// D-14: transition slot for Plan 05 remote reorder animation
transition: 'transform 150ms ease-out',
opacity: optimisticOpacity,
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Main row */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 'var(--space-2)',
minHeight: '44px',
padding: 'var(--space-2) var(--space-4)',
background: '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 */}
{isActive && (
<button
aria-label="Drag to reorder (available in next update)"
style={{
background: 'none',
border: 'none',
cursor: 'grab',
padding: 'var(--space-1)',
color: 'var(--color-text-muted)',
display: 'flex',
alignItems: 'center',
minWidth: '20px',
opacity: 0.5,
}}
>
<GripVertical size={16} aria-hidden="true" />
</button>
)}
{/* Checkbox (44px touch area, 20px visual) */}
<button
role="checkbox"
aria-checked={item.checked}
aria-label={item.text}
onClick={handleCheckboxClick}
style={{
width: '44px',
height: '44px',
minWidth: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
}}
>
<div
style={{
width: '20px',
height: '20px',
borderRadius: '4px',
border: item.checked ? 'none' : '2px solid var(--color-border)',
background: item.checked ? 'var(--color-member-0)' : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
{item.checked && (
<svg
width="12"
height="9"
viewBox="0 0 12 9"
fill="none"
aria-hidden="true"
>
<path
d="M1 4L4.5 7.5L11 1"
stroke="white"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</div>
</button>
{/* Item text — plain text only (T-04-06 XSS guard: no dangerouslySetInnerHTML) */}
<span
style={{
flex: 1,
fontSize: 'var(--text-body-size, 15px)',
fontWeight: 400,
color: item.checked ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
textDecoration: item.checked ? 'line-through' : 'none',
wordBreak: 'break-word',
}}
>
{item.text}
</span>
{/* Desktop delete button — visible on hover */}
{hovered && (
<button
onClick={handleDelete}
aria-label={`Delete ${item.text}`}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: 'var(--space-2)',
color: 'var(--color-destructive)',
display: 'flex',
alignItems: 'center',
}}
>
<Trash2 size={16} aria-hidden="true" />
</button>
)}
</div>
{/* Swipe-left delete zone (phone) */}
{swipeRevealed && (
<button
onClick={handleDelete}
aria-label={`Delete ${item.text}`}
style={{
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
width: '80px',
background: 'var(--color-destructive)',
color: '#fff',
border: 'none',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 'var(--text-label-size, 13px)',
fontWeight: 600,
}}
>
Delete
</button>
)}
</div>
)
}