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:
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* AddItemInput — sticky add-item text input at the bottom of ListDetail.
|
||||
*
|
||||
* Design contract (UI-SPEC §AddItemInput):
|
||||
* - Sticky at the bottom, above keyboard on mobile
|
||||
* - Horizontal flex: text input (flex:1) + "Add" button
|
||||
* - Input: --text-body-*, 44px min-height, placeholder "Add an item…"
|
||||
* - Button: "Add" label, --color-member-0 bg, white text, disabled when empty
|
||||
* - Submit: Enter key OR tap "Add" button
|
||||
*/
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
|
||||
interface AddItemInputProps {
|
||||
onAdd: (text: string) => void
|
||||
/** Whether the add mutation is pending (parent controls this for optimistic state) */
|
||||
isPending?: boolean
|
||||
}
|
||||
|
||||
export function AddItemInput({ onAdd, isPending = false }: AddItemInputProps) {
|
||||
const [text, setText] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function handleSubmit() {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return
|
||||
onAdd(trimmed)
|
||||
setText('')
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
display: 'flex',
|
||||
gap: 'var(--space-2)',
|
||||
padding: 'var(--space-3) var(--space-4)',
|
||||
background: 'var(--color-surface)',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
// Push above keyboard on iOS and bottom tab bar
|
||||
paddingBottom: 'calc(var(--space-3) + env(safe-area-inset-bottom, 0px))',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Add an item…"
|
||||
disabled={isPending}
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 'var(--text-body-size, 15px)',
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--space-1)',
|
||||
padding: 'var(--space-2) var(--space-4)',
|
||||
minHeight: '44px',
|
||||
color: 'var(--color-text-primary)',
|
||||
outline: 'none',
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-focus-ring, #4A90D9)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'var(--color-border)'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!text.trim() || isPending}
|
||||
aria-label="Add item"
|
||||
style={{
|
||||
background: 'var(--color-member-0)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 'var(--space-1)',
|
||||
padding: '0 var(--space-4)',
|
||||
minHeight: '44px',
|
||||
fontSize: 'var(--text-label-size, 13px)',
|
||||
fontWeight: 600,
|
||||
cursor: !text.trim() || isPending ? 'not-allowed' : 'pointer',
|
||||
opacity: !text.trim() || isPending ? 0.5 : 1,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user