ultisuite-client/components/gmail/mail-list-swipe-row.tsx

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)