add cancel swipe to JobListItem
This commit is contained in:
+175
-12
@@ -1,30 +1,193 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { formatTime } from "@/utils/datetime"
|
import { formatElapsed, formatTime } from "@/utils/datetime"
|
||||||
|
|
||||||
|
const SWIPE_THRESHOLD = 100
|
||||||
|
const CANCEL_THRESHOLD = 150
|
||||||
|
|
||||||
export interface JobListItemProps {
|
export interface JobListItemProps {
|
||||||
title: string
|
title: string
|
||||||
runningTime: string
|
|
||||||
progress: number
|
progress: number
|
||||||
startTime?: string
|
startTime?: string
|
||||||
eta?: string
|
eta?: string
|
||||||
className?: string
|
className?: string
|
||||||
|
onCancel?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JobListItem({ title, runningTime, progress, startTime, eta, className }: JobListItemProps) {
|
export function JobListItem({ title, progress, startTime, eta, className, onCancel }: JobListItemProps) {
|
||||||
|
const [displayTime, setDisplayTime] = useState(() =>
|
||||||
|
startTime ? formatElapsed(startTime) : 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
|
// Swipe state
|
||||||
|
const [offsetX, setOffsetX] = useState(0)
|
||||||
|
const [isSwiping, setIsSwiping] = useState(false)
|
||||||
|
const [isDismissing, setIsDismissing] = useState(false)
|
||||||
|
const startXRef = useRef(0)
|
||||||
|
const startYRef = useRef(0)
|
||||||
|
const currentOffsetRef = useRef(0)
|
||||||
|
const isTrackingRef = useRef(false)
|
||||||
|
const directionLockedRef = useRef<"horizontal" | "vertical" | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!startTime) return
|
||||||
|
|
||||||
|
intervalRef.current = setInterval(() => {
|
||||||
|
setDisplayTime(formatElapsed(startTime))
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (intervalRef.current) clearInterval(intervalRef.current)
|
||||||
|
}
|
||||||
|
}, [startTime])
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
if (onCancel) {
|
||||||
|
onCancel()
|
||||||
|
} else {
|
||||||
|
console.log(`Cancel job: ${title}`)
|
||||||
|
}
|
||||||
|
}, [onCancel, title])
|
||||||
|
|
||||||
|
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||||
|
// Only track primary pointer (left mouse / single touch)
|
||||||
|
if (e.button !== 0) return
|
||||||
|
startXRef.current = e.clientX
|
||||||
|
startYRef.current = e.clientY
|
||||||
|
isTrackingRef.current = true
|
||||||
|
directionLockedRef.current = null
|
||||||
|
currentOffsetRef.current = 0
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePointerMove = useCallback((e: React.PointerEvent) => {
|
||||||
|
if (!isTrackingRef.current) return
|
||||||
|
|
||||||
|
const deltaX = e.clientX - startXRef.current
|
||||||
|
const deltaY = e.clientY - startYRef.current
|
||||||
|
|
||||||
|
// Determine direction lock on first significant movement
|
||||||
|
if (!directionLockedRef.current) {
|
||||||
|
const absDx = Math.abs(deltaX)
|
||||||
|
const absDy = Math.abs(deltaY)
|
||||||
|
if (absDx < 5 && absDy < 5) return // Dead zone
|
||||||
|
|
||||||
|
if (absDx > absDy) {
|
||||||
|
directionLockedRef.current = "horizontal"
|
||||||
|
setIsSwiping(true)
|
||||||
|
// Capture pointer to continue receiving events even outside element
|
||||||
|
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
|
} else {
|
||||||
|
directionLockedRef.current = "vertical"
|
||||||
|
isTrackingRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directionLockedRef.current !== "horizontal") return
|
||||||
|
|
||||||
|
// Only allow swiping left (negative deltaX), with resistance for right swipe
|
||||||
|
const clampedOffset = deltaX > 0 ? deltaX * 0.2 : deltaX
|
||||||
|
currentOffsetRef.current = clampedOffset
|
||||||
|
setOffsetX(clampedOffset)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handlePointerUp = useCallback(() => {
|
||||||
|
if (!isTrackingRef.current && !isSwiping) return
|
||||||
|
isTrackingRef.current = false
|
||||||
|
|
||||||
|
const offset = currentOffsetRef.current
|
||||||
|
|
||||||
|
if (offset < -CANCEL_THRESHOLD) {
|
||||||
|
// Dismiss: animate off-screen then trigger cancel
|
||||||
|
setIsDismissing(true)
|
||||||
|
setOffsetX(-window.innerWidth)
|
||||||
|
setTimeout(() => {
|
||||||
|
handleCancel()
|
||||||
|
setIsDismissing(false)
|
||||||
|
setOffsetX(0)
|
||||||
|
setIsSwiping(false)
|
||||||
|
}, 300)
|
||||||
|
} else {
|
||||||
|
// Snap back
|
||||||
|
setOffsetX(0)
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsSwiping(false)
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
}, [isSwiping, handleCancel])
|
||||||
|
|
||||||
|
const handlePointerCancel = useCallback(() => {
|
||||||
|
isTrackingRef.current = false
|
||||||
|
directionLockedRef.current = null
|
||||||
|
setOffsetX(0)
|
||||||
|
setIsSwiping(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const swipeProgress = Math.min(Math.abs(offsetX) / SWIPE_THRESHOLD, 1)
|
||||||
|
const pastThreshold = Math.abs(offsetX) > SWIPE_THRESHOLD
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex flex-col gap-2 rounded-lg border p-3", className)}>
|
<div
|
||||||
<div className="flex items-center justify-between">
|
className={cn("relative overflow-hidden rounded-lg", className)}
|
||||||
<span className="text-sm font-medium">{title}</span>
|
style={{ touchAction: "pan-y" }}
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">{runningTime}</span>
|
>
|
||||||
|
{/* Background layer revealed on swipe */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 flex items-center justify-end rounded-lg px-4 transition-colors",
|
||||||
|
pastThreshold ? "bg-destructive" : "bg-destructive/60",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
opacity: swipeProgress,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
className={cn(
|
||||||
|
"size-5 text-white transition-transform",
|
||||||
|
pastThreshold && "scale-125",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={progress} />
|
|
||||||
{(startTime || eta) && (
|
{/* Foreground card */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative flex flex-col gap-2 rounded-lg border bg-background p-3",
|
||||||
|
!isSwiping && !isDismissing && "transition-transform duration-200 ease-out",
|
||||||
|
isDismissing && "transition-transform duration-300 ease-in",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: `translateX(${offsetX}px)`,
|
||||||
|
}}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
onPointerCancel={handlePointerCancel}
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">{startTime ? formatTime(startTime) : ""} started</span>
|
<span className="text-sm font-medium">{title}</span>
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">{eta ? `ETA ${formatTime(eta)}` : ""}</span>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:text-destructive focus:outline-none focus:ring-2 focus:ring-destructive focus:ring-offset-2 transition-colors"
|
||||||
|
aria-label={`Cancel job: ${title}`}
|
||||||
|
onClick={handleCancel}
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Progress value={progress} />
|
||||||
|
{(startTime || eta) && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">{displayTime}</span>
|
||||||
|
<span className="text-xs text-muted-foreground tabular-nums">{eta ? `ETA ${formatTime(eta)}` : ""}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user