Files
familysync/apps/pwa/src/components/ItemRow.tsx
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
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.
2026-06-11 20:35:18 -04:00

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>
);
}