SoftTraderFrontend/components/widgets/SwipeableRow.tsx
2026-03-20 11:10:13 -04:00

226 lines
7.6 KiB
TypeScript

'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<void>
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<HTMLDivElement>(null)
const [translateX, setTranslateX] = useState(0)
const [isDragging, setIsDragging] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
const [deleteError, setDeleteError] = useState<string | null>(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 (
<div ref={containerRef} className="relative overflow-hidden">
{/* Error banner (shown when delete fails) */}
{deleteError && (
<div className="absolute inset-x-0 -top-0 z-20 bg-negative text-white text-xs px-3 py-1 text-center">
{deleteError}
</div>
)}
{/* Delete action revealed behind the card */}
<div
className="absolute inset-y-0 right-0 flex items-center justify-center bg-negative text-white font-semibold select-none text-sm"
style={{
width: deleteWidth,
cursor: isDeleting ? 'default' : 'pointer',
opacity: isDeleting ? 0.6 : 1,
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
onClick={handleDeleteClick}
>
{isDeleting ? '...' : 'Delete'}
</div>
{/* Swipeable card layer */}
<div
className="relative select-none"
style={{
transform: `translateX(${translateX}px)`,
transition: isDragging ? 'none' : 'transform 220ms cubic-bezier(0.25, 0.46, 0.45, 0.94)',
touchAction: 'pan-y',
opacity: isDeleting ? 0.5 : 1,
pointerEvents: isDeleting ? 'none' : 'auto',
}}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onClick={handleCardClick}
>
{children}
</div>
</div>
)
}