Milestone v1.0: FamilySync MVP #1
@@ -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<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() {
|
||||
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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
// D-14: transition slot for Plan 05 remote reorder animation
|
||||
transition: 'transform 150ms ease-out',
|
||||
opacity: optimisticOpacity,
|
||||
// D-14: transition slot for remote reorder animation
|
||||
transform: transformStr,
|
||||
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)}
|
||||
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 && (
|
||||
<button
|
||||
aria-label="Drag to reorder (available in next update)"
|
||||
{...listeners}
|
||||
aria-label="Drag to reorder"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'grab',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
padding: 'var(--space-1)',
|
||||
color: 'var(--color-text-muted)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
minWidth: '20px',
|
||||
opacity: 0.5,
|
||||
touchAction: 'none', // prevent browser scroll interference during drag
|
||||
}}
|
||||
>
|
||||
<GripVertical size={16} aria-hidden="true" />
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
/**
|
||||
* 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)
|
||||
* - Splits items into active (!checked, sorted rank ASC) and completed (checked) (D-05)
|
||||
* - Optimistic mutations for add / check / delete (D-07/D-09)
|
||||
* - Delete-wins: no rollback on item delete (D-09)
|
||||
* - 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.
|
||||
* Drag-to-reorder (dnd-kit) is wired in Plan 05.
|
||||
*
|
||||
* 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
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 {
|
||||
fetchListItems,
|
||||
addItem,
|
||||
@@ -77,6 +98,24 @@ export function ListDetail() {
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
// refetchInterval: 30000 = D-12 polling fallback (always active; SSE layered in Plan 06)
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
@@ -102,7 +141,6 @@ export function ListDetail() {
|
||||
|
||||
// Compute optimistic rank: after the last active item
|
||||
const lastRank = activeItems.at(-1)?.rank ?? null
|
||||
const { generateKeyBetween } = await import('fractional-indexing')
|
||||
const optimisticRank = generateKeyBetween(lastRank, null)
|
||||
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
function handleAdd(text: string) {
|
||||
@@ -186,6 +252,47 @@ export function ListDetail() {
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
if (isNaN(parsedListId)) {
|
||||
@@ -295,22 +402,33 @@ export function ListDetail() {
|
||||
|
||||
{!isLoading && !isError && allItems.length === 0 && <ListEmptyState />}
|
||||
|
||||
{/* Active items section */}
|
||||
{/* Active items section — wrapped in DndContext + SortableContext (LIST-03) */}
|
||||
{activeItems.length > 0 && (
|
||||
<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}
|
||||
/>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={activeItems.map((i) => i.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<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>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* Completed section (D-05) — collapsible, default expanded */}
|
||||
|
||||
Reference in New Issue
Block a user