Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
267 lines
8.9 KiB
TypeScript
267 lines
8.9 KiB
TypeScript
/**
|
|
* 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<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);
|
|
}
|
|
|
|
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 (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={{
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
// 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}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
{/* Main row */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 'var(--space-2)',
|
|
minHeight: '44px',
|
|
padding: 'var(--space-2) var(--space-4)',
|
|
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 — listeners scoped to this button only (not whole row).
|
|
Only active items are draggable (completed items are not reorderable). */}
|
|
{isActive && (
|
|
<button
|
|
{...listeners}
|
|
aria-label="Drag to reorder"
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: isDragging ? 'grabbing' : 'grab',
|
|
padding: 'var(--space-1)',
|
|
color: 'var(--color-text-muted)',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
minWidth: '20px',
|
|
touchAction: 'none', // prevent browser scroll interference during drag
|
|
}}
|
|
>
|
|
<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>
|
|
);
|
|
}
|