226 lines
7.6 KiB
TypeScript
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>
|
|
)
|
|
}
|