'use client' import { useRef, useState, useEffect, useCallback, type ReactNode } from 'react' interface SwipeableRowProps { children: ReactNode /** Return a Promise — resolves on success, rejects on failure */ onDelete: () => Promise onTap?: () => void isOpen: boolean onOpenChange: (open: boolean) => void deleteWidth?: number threshold?: number tapSlop?: number } export function SwipeableRow({ children, onDelete, onTap, isOpen, onOpenChange, deleteWidth = 80, threshold = 0.4, tapSlop = 6, }: SwipeableRowProps) { const containerRef = useRef(null) const [translateX, setTranslateX] = useState(0) const [isDragging, setIsDragging] = useState(false) const [isDeleting, setIsDeleting] = useState(false) const [deleteError, setDeleteError] = useState(null) const startX = useRef(0) const startY = useRef(0) const currentX = useRef(0) const startTranslate = useRef(0) const didMove = useRef(false) const dragAxisLocked = useRef<'h' | 'v' | null>(null) // Sync open/close from parent useEffect(() => { if (!isDragging) { setTranslateX(isOpen ? -deleteWidth : 0) } }, [isOpen, deleteWidth, isDragging]) // Clear error after 3 seconds useEffect(() => { if (!deleteError) return const t = setTimeout(() => setDeleteError(null), 3000) return () => clearTimeout(t) }, [deleteError]) // ── Gesture start ────────────────────────────────────────────────────────── const handleStart = useCallback((clientX: number, clientY: number) => { if (isDeleting) return startX.current = clientX startY.current = clientY startTranslate.current = isOpen ? -deleteWidth : 0 currentX.current = startTranslate.current didMove.current = false dragAxisLocked.current = null setIsDragging(true) }, [isOpen, deleteWidth, isDeleting]) // ── Gesture move ─────────────────────────────────────────────────────────── const handleMove = useCallback((clientX: number, clientY: number) => { const dx = clientX - startX.current const dy = clientY - startY.current const absDx = Math.abs(dx) const absDy = Math.abs(dy) if (!dragAxisLocked.current && (absDx > 3 || absDy > 3)) { dragAxisLocked.current = absDx >= absDy ? 'h' : 'v' } if (dragAxisLocked.current === 'v') { setIsDragging(false) return } if (dragAxisLocked.current !== 'h') return if (absDx > tapSlop || absDy > tapSlop) { didMove.current = true } let newTranslate = startTranslate.current + dx newTranslate = Math.max(-deleteWidth, Math.min(0, newTranslate)) currentX.current = newTranslate setTranslateX(newTranslate) }, [deleteWidth, tapSlop]) // ── Gesture end ──────────────────────────────────────────────────────────── const handleEnd = useCallback(() => { setIsDragging(false) if (isOpen && !didMove.current) { setTranslateX(0) onOpenChange(false) return } const thresholdPx = deleteWidth * threshold const shouldOpen = currentX.current < -thresholdPx if (shouldOpen) { setTranslateX(-deleteWidth) onOpenChange(true) } else { setTranslateX(0) onOpenChange(false) } if (!didMove.current && !isOpen && onTap) { onTap() } }, [isOpen, deleteWidth, threshold, onOpenChange, onTap]) // ── Mouse events ─────────────────────────────────────────────────────────── const handleMouseDown = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return e.preventDefault() handleStart(e.clientX, e.clientY) }, [handleStart]) useEffect(() => { if (!isDragging) return const onMouseMove = (e: MouseEvent) => handleMove(e.clientX, e.clientY) const onMouseUp = () => handleEnd() window.addEventListener('mousemove', onMouseMove) window.addEventListener('mouseup', onMouseUp) return () => { window.removeEventListener('mousemove', onMouseMove) window.removeEventListener('mouseup', onMouseUp) } }, [isDragging, handleMove, handleEnd]) // ── Touch events ─────────────────────────────────────────────────────────── const handleTouchStart = useCallback((e: React.TouchEvent) => { const t = e.touches[0] handleStart(t.clientX, t.clientY) }, [handleStart]) const handleTouchMove = useCallback((e: React.TouchEvent) => { const t = e.touches[0] handleMove(t.clientX, t.clientY) if (dragAxisLocked.current === 'h') e.preventDefault() }, [handleMove]) const handleTouchEnd = useCallback(() => handleEnd(), [handleEnd]) // ── Block card click when dragged or open ───────────────────────────────── const handleCardClick = useCallback((e: React.MouseEvent) => { if (didMove.current || isOpen) { e.preventDefault() e.stopPropagation() } }, [isOpen]) // ── Delete — async, with disabled guard and error feedback ──────────────── const handleDeleteClick = useCallback(async (e: React.MouseEvent) => { e.preventDefault() e.stopPropagation() if (isDeleting) return setIsDeleting(true) setDeleteError(null) try { await onDelete() // onDelete resolves → parent removes item from list, row unmounts } catch (err) { setDeleteError(err instanceof Error ? err.message : 'Failed to delete') setIsDeleting(false) // Snap row back closed so error state is visible setTranslateX(0) onOpenChange(false) } }, [isDeleting, onDelete, onOpenChange]) return (
{/* Error banner (shown when delete fails) */} {deleteError && (
{deleteError}
)} {/* Delete action revealed behind the card */}
e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} onClick={handleDeleteClick} > {isDeleting ? '...' : 'Delete'}
{/* Swipeable card layer */}
{children}
) }