353 lines
10 KiB
TypeScript
353 lines
10 KiB
TypeScript
"use client"
|
|
|
|
import { memo, useCallback, useRef, type ReactNode, type TouchEvent } from "react"
|
|
import { Archive, Star, Tag, Trash2 } from "lucide-react"
|
|
import { cn } from "@/lib/utils"
|
|
|
|
const SNAP_MS = 200
|
|
const SNAP_EASE = "cubic-bezier(0.25, 0.46, 0.45, 0.94)"
|
|
const LEFT_PANEL_PX = 240
|
|
const RIGHT_PANEL_PX = 100
|
|
const OPEN_THRESHOLD_RATIO = 0.15
|
|
const OPEN_VELOCITY = 0.28
|
|
|
|
type Props = {
|
|
enabled: boolean
|
|
emailId: string
|
|
isOpen: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
onArchive: () => void
|
|
onDelete: () => void
|
|
onStar: () => void
|
|
onLabel: () => void
|
|
className?: string
|
|
children: ReactNode
|
|
}
|
|
|
|
function MailListSwipeRowInner({
|
|
enabled,
|
|
emailId,
|
|
isOpen,
|
|
onOpenChange,
|
|
onArchive,
|
|
onDelete,
|
|
onStar,
|
|
onLabel,
|
|
className,
|
|
children,
|
|
}: Props) {
|
|
const rowRef = useRef<HTMLDivElement>(null)
|
|
const fgRef = useRef<HTMLDivElement>(null)
|
|
const leftPanelRef = useRef<HTMLDivElement>(null)
|
|
const rightPanelRef = useRef<HTMLDivElement>(null)
|
|
|
|
const offsetRef = useRef(0)
|
|
const directionRef = useRef<"none" | "left" | "right">("none")
|
|
const touchRef = useRef<{
|
|
startX: number
|
|
startY: number
|
|
startOffset: number
|
|
active: boolean
|
|
axis: "none" | "x" | "y"
|
|
startTime: number
|
|
prevX: number
|
|
prevT: number
|
|
} | null>(null)
|
|
const suppressClickUntilRef = useRef(0)
|
|
|
|
|
|
const applyOffset = useCallback((px: number) => {
|
|
offsetRef.current = px
|
|
const fg = fgRef.current
|
|
if (!fg) return
|
|
fg.style.transform = `translate3d(${px}px,0,0)`
|
|
|
|
const lp = leftPanelRef.current
|
|
const rp = rightPanelRef.current
|
|
if (lp) lp.style.visibility = px > 0 ? "visible" : "hidden"
|
|
if (rp) rp.style.visibility = px < 0 ? "visible" : "hidden"
|
|
}, [])
|
|
|
|
const animateTo = useCallback(
|
|
(px: number) => {
|
|
const fg = fgRef.current
|
|
if (!fg) return
|
|
fg.style.transition = `transform ${SNAP_MS}ms ${SNAP_EASE}`
|
|
applyOffset(px)
|
|
window.setTimeout(() => {
|
|
if (fg) fg.style.transition = "none"
|
|
}, SNAP_MS + 10)
|
|
},
|
|
[applyOffset]
|
|
)
|
|
|
|
const close = useCallback(() => {
|
|
animateTo(0)
|
|
directionRef.current = "none"
|
|
if (isOpen) onOpenChange(false)
|
|
}, [animateTo, isOpen, onOpenChange])
|
|
|
|
|
|
const onTouchStart = useCallback(
|
|
(e: TouchEvent<HTMLDivElement>) => {
|
|
if (!enabled) return
|
|
const t = e.touches[0]
|
|
if (!t) return
|
|
const fg = fgRef.current
|
|
if (fg) fg.style.transition = "none"
|
|
touchRef.current = {
|
|
startX: t.clientX,
|
|
startY: t.clientY,
|
|
startOffset: offsetRef.current,
|
|
active: true,
|
|
axis: "none",
|
|
startTime: performance.now(),
|
|
prevX: t.clientX,
|
|
prevT: performance.now(),
|
|
}
|
|
},
|
|
[enabled]
|
|
)
|
|
|
|
const onTouchMove = useCallback(
|
|
(e: TouchEvent<HTMLDivElement>) => {
|
|
const tr = touchRef.current
|
|
if (!tr?.active) return
|
|
const t = e.touches[0]
|
|
if (!t) return
|
|
|
|
const dx = t.clientX - tr.startX
|
|
const dy = t.clientY - tr.startY
|
|
|
|
if (tr.axis === "none") {
|
|
if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return
|
|
if (Math.abs(dy) > Math.abs(dx)) {
|
|
tr.axis = "y"
|
|
tr.active = false
|
|
return
|
|
}
|
|
tr.axis = "x"
|
|
}
|
|
|
|
if (tr.axis !== "x") return
|
|
e.preventDefault()
|
|
|
|
let raw = tr.startOffset + dx
|
|
|
|
// Clamp to panel width (no overswipe)
|
|
if (raw > LEFT_PANEL_PX) raw = LEFT_PANEL_PX
|
|
if (raw < -RIGHT_PANEL_PX) raw = -RIGHT_PANEL_PX
|
|
|
|
tr.prevX = t.clientX
|
|
tr.prevT = performance.now()
|
|
applyOffset(raw)
|
|
},
|
|
[applyOffset]
|
|
)
|
|
|
|
const onTouchEnd = useCallback(() => {
|
|
const tr = touchRef.current
|
|
touchRef.current = null
|
|
if (!tr?.active || tr.axis !== "x") return
|
|
|
|
suppressClickUntilRef.current = performance.now() + 350
|
|
|
|
const offset = offsetRef.current
|
|
const elapsed = Math.max(1, performance.now() - tr.startTime)
|
|
const instantVelocity = (tr.prevX - tr.startX) / elapsed
|
|
|
|
// If panel was open and user swiped opposite direction → just close
|
|
if (directionRef.current === "right" && offset <= 0) {
|
|
animateTo(0)
|
|
directionRef.current = "none"
|
|
if (isOpen) onOpenChange(false)
|
|
return
|
|
}
|
|
if (directionRef.current === "left" && offset >= 0) {
|
|
animateTo(0)
|
|
directionRef.current = "none"
|
|
if (isOpen) onOpenChange(false)
|
|
return
|
|
}
|
|
|
|
// Open/close based on threshold + velocity
|
|
// Closing requires less energy than opening (lower threshold & velocity)
|
|
const closing = isOpen && (
|
|
(directionRef.current === "right" && offset < LEFT_PANEL_PX) ||
|
|
(directionRef.current === "left" && -offset < RIGHT_PANEL_PX)
|
|
)
|
|
const closeThreshold = 0.08
|
|
const closeVelocity = 0.12
|
|
|
|
if (offset > 0) {
|
|
if (closing) {
|
|
const shouldStayOpen =
|
|
offset >= LEFT_PANEL_PX * (1 - closeThreshold) &&
|
|
instantVelocity >= -closeVelocity
|
|
if (shouldStayOpen) {
|
|
animateTo(LEFT_PANEL_PX)
|
|
} else {
|
|
animateTo(0)
|
|
directionRef.current = "none"
|
|
onOpenChange(false)
|
|
}
|
|
} else {
|
|
const shouldOpen =
|
|
offset >= LEFT_PANEL_PX * OPEN_THRESHOLD_RATIO ||
|
|
(instantVelocity > OPEN_VELOCITY && offset > 20)
|
|
if (shouldOpen) {
|
|
animateTo(LEFT_PANEL_PX)
|
|
directionRef.current = "right"
|
|
if (!isOpen) onOpenChange(true)
|
|
} else {
|
|
animateTo(0)
|
|
directionRef.current = "none"
|
|
if (isOpen) onOpenChange(false)
|
|
}
|
|
}
|
|
} else if (offset < 0) {
|
|
if (closing) {
|
|
const shouldStayOpen =
|
|
-offset >= RIGHT_PANEL_PX * (1 - closeThreshold) &&
|
|
instantVelocity <= closeVelocity
|
|
if (shouldStayOpen) {
|
|
animateTo(-RIGHT_PANEL_PX)
|
|
} else {
|
|
animateTo(0)
|
|
directionRef.current = "none"
|
|
onOpenChange(false)
|
|
}
|
|
} else {
|
|
const shouldOpen =
|
|
-offset >= RIGHT_PANEL_PX * OPEN_THRESHOLD_RATIO ||
|
|
(instantVelocity < -OPEN_VELOCITY && offset < -20)
|
|
if (shouldOpen) {
|
|
animateTo(-RIGHT_PANEL_PX)
|
|
directionRef.current = "left"
|
|
if (!isOpen) onOpenChange(true)
|
|
} else {
|
|
animateTo(0)
|
|
directionRef.current = "none"
|
|
if (isOpen) onOpenChange(false)
|
|
}
|
|
}
|
|
} else {
|
|
directionRef.current = "none"
|
|
if (isOpen) onOpenChange(false)
|
|
}
|
|
}, [
|
|
animateTo,
|
|
isOpen,
|
|
onOpenChange,
|
|
])
|
|
|
|
const onTouchCancel = useCallback(() => {
|
|
const tr = touchRef.current
|
|
touchRef.current = null
|
|
if (!tr?.active || tr.axis !== "x") return
|
|
animateTo(0)
|
|
directionRef.current = "none"
|
|
if (isOpen) onOpenChange(false)
|
|
}, [animateTo, isOpen, onOpenChange])
|
|
|
|
const handleClickCapture = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (!enabled) return
|
|
if (performance.now() < suppressClickUntilRef.current) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
return
|
|
}
|
|
if (offsetRef.current !== 0) {
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
close()
|
|
}
|
|
},
|
|
[close, enabled]
|
|
)
|
|
|
|
// Close when isOpen flips to false externally (another row opened)
|
|
if (!isOpen && offsetRef.current !== 0 && !touchRef.current?.active) {
|
|
const fg = fgRef.current
|
|
if (fg) {
|
|
fg.style.transition = `transform ${SNAP_MS}ms ${SNAP_EASE}`
|
|
applyOffset(0)
|
|
directionRef.current = "none"
|
|
window.setTimeout(() => {
|
|
if (fg) fg.style.transition = "none"
|
|
}, SNAP_MS + 10)
|
|
}
|
|
}
|
|
|
|
if (!enabled) {
|
|
return <div className={className}>{children}</div>
|
|
}
|
|
|
|
return (
|
|
<div ref={rowRef} className={cn("relative overflow-hidden", className)} data-swipe-row-id={emailId}>
|
|
{/* Left actions (swipe right to reveal) */}
|
|
<div className="invisible absolute inset-y-0 left-0 flex" ref={leftPanelRef}>
|
|
<button
|
|
type="button"
|
|
aria-label="Mettre en suivi"
|
|
className="flex h-full w-20 shrink-0 flex-col items-center justify-center gap-1 bg-[#f4b400] px-1 text-[11px] font-medium text-white"
|
|
onClick={(e) => { e.stopPropagation(); onStar(); close() }}
|
|
>
|
|
<Star className="size-5 fill-white text-white" strokeWidth={0} />
|
|
<span className="max-w-full truncate text-center">Suivi</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="Ajouter un libellé"
|
|
className="flex h-full w-20 shrink-0 flex-col items-center justify-center gap-1 bg-[#34a853] px-1 text-[11px] font-medium text-white"
|
|
onClick={(e) => { e.stopPropagation(); onLabel(); close() }}
|
|
>
|
|
<Tag className="size-5 text-white" strokeWidth={1.75} />
|
|
<span className="max-w-full truncate text-center">Libellé</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="Archiver"
|
|
className="flex h-full w-20 shrink-0 flex-col items-center justify-center gap-1 bg-[#1a73e8] px-1 text-[11px] font-medium text-white"
|
|
onClick={(e) => { e.stopPropagation(); onArchive(); close() }}
|
|
>
|
|
<Archive className="size-5 text-white" strokeWidth={1.75} />
|
|
<span className="max-w-full truncate text-center">Archiver</span>
|
|
</button>
|
|
</div>
|
|
|
|
|
|
{/* Right actions (swipe left to reveal) */}
|
|
<div className="invisible absolute inset-y-0 right-0 flex" ref={rightPanelRef}>
|
|
<button
|
|
type="button"
|
|
aria-label="Supprimer"
|
|
className="flex h-full w-[100px] shrink-0 flex-col items-center justify-center gap-1 bg-[#d93025] px-2 text-[11px] font-medium text-white"
|
|
onClick={(e) => { e.stopPropagation(); onDelete(); close() }}
|
|
>
|
|
<Trash2 className="size-5 text-white" strokeWidth={1.75} />
|
|
<span className="max-w-full truncate text-center">Supprimer</span>
|
|
</button>
|
|
</div>
|
|
|
|
|
|
{/* Foreground (row content) */}
|
|
<div
|
|
ref={fgRef}
|
|
className="relative z-1 bg-inherit will-change-transform"
|
|
style={{ transform: "translate3d(0,0,0)" }}
|
|
onTouchStart={onTouchStart}
|
|
onTouchMove={onTouchMove}
|
|
onTouchEnd={onTouchEnd}
|
|
onTouchCancel={onTouchCancel}
|
|
onClickCapture={handleClickCapture}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export const MailListSwipeRow = memo(MailListSwipeRowInner)
|