268 lines
7.8 KiB
TypeScript
268 lines
7.8 KiB
TypeScript
'use client'
|
|
|
|
import * as React from 'react'
|
|
import { TrashIcon, BookmarkSquareIcon } from '@heroicons/react/24/outline'
|
|
|
|
function cn(...parts: Array<string | false | null | undefined>) {
|
|
return parts.filter(Boolean).join(' ')
|
|
}
|
|
|
|
export type SwipeAction = {
|
|
label: React.ReactNode
|
|
className?: string
|
|
}
|
|
|
|
export type SwipeCardProps = {
|
|
children: React.ReactNode
|
|
|
|
/** Swipe an/aus (z.B. nur mobile view) */
|
|
enabled?: boolean
|
|
/** blockiert Swipe + Tap */
|
|
disabled?: boolean
|
|
|
|
/** Tap ohne Swipe (z.B. Player öffnen) */
|
|
onTap?: () => void
|
|
|
|
/**
|
|
* Rückgabe:
|
|
* - true/void => Aktion erfolgreich, Karte fliegt raus (translate offscreen)
|
|
* - false => Aktion fehlgeschlagen => Karte snappt zurück
|
|
*/
|
|
onSwipeLeft: () => boolean | void | Promise<boolean | void>
|
|
onSwipeRight: () => boolean | void | Promise<boolean | void>
|
|
|
|
/** optionales Styling am äußeren Wrapper */
|
|
className?: string
|
|
|
|
/** Action-Bereiche */
|
|
leftAction?: SwipeAction // standard: Behalten
|
|
rightAction?: SwipeAction // standard: Löschen
|
|
|
|
/** Ab welcher Strecke wird ausgelöst? */
|
|
thresholdPx?: number
|
|
thresholdRatio?: number // Anteil der Kartenbreite, z.B. 0.35
|
|
|
|
/** Animation timings */
|
|
snapMs?: number
|
|
commitMs?: number
|
|
}
|
|
|
|
export type SwipeCardHandle = {
|
|
swipeLeft: (opts?: { runAction?: boolean }) => Promise<boolean>
|
|
swipeRight: (opts?: { runAction?: boolean }) => Promise<boolean>
|
|
reset: () => void
|
|
}
|
|
|
|
const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(function SwipeCard(
|
|
{
|
|
children,
|
|
enabled = true,
|
|
disabled = false,
|
|
onTap,
|
|
onSwipeLeft,
|
|
onSwipeRight,
|
|
className,
|
|
leftAction = {
|
|
label: (
|
|
<span className="inline-flex flex-col items-center gap-1 font-semibold leading-tight">
|
|
<BookmarkSquareIcon className="h-6 w-6" aria-hidden="true" />
|
|
<span>Behalten</span>
|
|
</span>
|
|
),
|
|
className: 'bg-emerald-500/20 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300',
|
|
},
|
|
rightAction = {
|
|
label: (
|
|
<span className="inline-flex flex-col items-center gap-1 font-semibold leading-tight">
|
|
<TrashIcon className="h-6 w-6" aria-hidden="true" />
|
|
<span>Löschen</span>
|
|
</span>
|
|
),
|
|
className: 'bg-red-500/20 text-red-800 dark:bg-red-500/15 dark:text-red-300',
|
|
},
|
|
thresholdPx = 120,
|
|
thresholdRatio = 0.35,
|
|
snapMs = 180,
|
|
commitMs = 180,
|
|
},
|
|
ref
|
|
) {
|
|
|
|
const cardRef = React.useRef<HTMLDivElement | null>(null)
|
|
|
|
const pointer = React.useRef<{
|
|
id: number | null
|
|
x: number
|
|
y: number
|
|
dragging: boolean
|
|
}>({ id: null, x: 0, y: 0, dragging: false })
|
|
|
|
const [dx, setDx] = React.useState(0)
|
|
const [armedDir, setArmedDir] = React.useState<null | 'left' | 'right'>(null)
|
|
const [animMs, setAnimMs] = React.useState<number>(0)
|
|
|
|
const reset = React.useCallback(() => {
|
|
setAnimMs(snapMs)
|
|
setDx(0)
|
|
setArmedDir(null)
|
|
window.setTimeout(() => setAnimMs(0), snapMs)
|
|
}, [snapMs])
|
|
|
|
const commit = React.useCallback(
|
|
async (dir: 'left' | 'right', runAction: boolean) => {
|
|
const el = cardRef.current
|
|
const w = el?.offsetWidth || 360
|
|
|
|
// rausfliegen lassen
|
|
setAnimMs(commitMs)
|
|
setArmedDir(dir === 'right' ? 'right' : 'left')
|
|
setDx(dir === 'right' ? w + 40 : -(w + 40))
|
|
|
|
let ok: boolean | void = true
|
|
if (runAction) {
|
|
try {
|
|
ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft()
|
|
} catch {
|
|
ok = false
|
|
}
|
|
}
|
|
|
|
// wenn Aktion fehlschlägt => zurücksnappen
|
|
if (ok === false) {
|
|
setAnimMs(snapMs)
|
|
setArmedDir(null)
|
|
setDx(0)
|
|
window.setTimeout(() => setAnimMs(0), snapMs)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
},
|
|
[commitMs, onSwipeLeft, onSwipeRight, snapMs]
|
|
)
|
|
|
|
React.useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
swipeLeft: (opts) => commit('left', opts?.runAction ?? true),
|
|
swipeRight: (opts) => commit('right', opts?.runAction ?? true),
|
|
reset: () => reset(),
|
|
}),
|
|
[commit, reset]
|
|
)
|
|
|
|
return (
|
|
<div className={cn('relative overflow-hidden rounded-lg', className)}>
|
|
{/* Background actions (100% je Richtung, animiert) */}
|
|
<div className="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
|
|
<div
|
|
className={cn(
|
|
'absolute inset-0 transition-opacity duration-200 ease-out',
|
|
dx === 0 ? 'opacity-0' : 'opacity-100',
|
|
dx > 0 ? leftAction.className : rightAction.className
|
|
)}
|
|
/>
|
|
|
|
<div
|
|
className={cn(
|
|
'absolute inset-0 flex items-center transition-all duration-200 ease-out'
|
|
)}
|
|
style={{
|
|
transform: `translateX(${Math.max(-24, Math.min(24, dx / 8))}px)`,
|
|
opacity: dx === 0 ? 0 : 1,
|
|
justifyContent: dx > 0 ? 'flex-start' : 'flex-end',
|
|
paddingLeft: dx > 0 ? 16 : 0,
|
|
paddingRight: dx > 0 ? 0 : 16,
|
|
}}
|
|
>
|
|
{dx > 0 ? leftAction.label : rightAction.label}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Foreground (moves) */}
|
|
<div
|
|
ref={cardRef}
|
|
className="relative"
|
|
style={{
|
|
transform: `translateX(${dx}px)`,
|
|
transition: animMs ? `transform ${animMs}ms ease` : undefined,
|
|
touchAction: 'pan-y', // wichtig: vertikales Scrollen zulassen
|
|
}}
|
|
onPointerDown={(e) => {
|
|
if (!enabled || disabled) return
|
|
pointer.current = { id: e.pointerId, x: e.clientX, y: e.clientY, dragging: false }
|
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
}}
|
|
onPointerMove={(e) => {
|
|
if (!enabled || disabled) return
|
|
if (pointer.current.id !== e.pointerId) return
|
|
|
|
const ddx = e.clientX - pointer.current.x
|
|
const ddy = e.clientY - pointer.current.y
|
|
|
|
// Erst entscheiden ob wir überhaupt "draggen"
|
|
if (!pointer.current.dragging) {
|
|
// wenn Nutzer vertikal scrollt, nicht hijacken
|
|
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) return
|
|
if (Math.abs(ddx) < 6) return
|
|
pointer.current.dragging = true
|
|
}
|
|
|
|
setAnimMs(0)
|
|
setDx(ddx)
|
|
|
|
const el = cardRef.current
|
|
const w = el?.offsetWidth || 360
|
|
const threshold = Math.min(thresholdPx, w * thresholdRatio)
|
|
|
|
setArmedDir(ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null)
|
|
}}
|
|
onPointerUp={(e) => {
|
|
if (!enabled || disabled) return
|
|
if (pointer.current.id !== e.pointerId) return
|
|
|
|
const el = cardRef.current
|
|
const w = el?.offsetWidth || 360
|
|
const threshold = Math.min(thresholdPx, w * thresholdRatio)
|
|
|
|
const wasDragging = pointer.current.dragging
|
|
pointer.current.id = null
|
|
|
|
if (!wasDragging) {
|
|
reset()
|
|
onTap?.()
|
|
return
|
|
}
|
|
|
|
if (dx > threshold) {
|
|
void commit('right', true) // keep
|
|
} else if (dx < -threshold) {
|
|
void commit('left', true) // delete
|
|
} else {
|
|
reset()
|
|
}
|
|
}}
|
|
onPointerCancel={() => {
|
|
if (!enabled || disabled) return
|
|
reset()
|
|
}}
|
|
>
|
|
<div className="relative">
|
|
<div className="relative z-10">{children}</div>
|
|
|
|
{/* ✅ Overlay liegt ÜBER dem Inhalt */}
|
|
<div
|
|
className={cn(
|
|
'absolute inset-0 z-20 pointer-events-none transition-opacity duration-150 rounded-lg',
|
|
armedDir === 'right' && 'bg-emerald-500/20 opacity-100',
|
|
armedDir === 'left' && 'bg-red-500/20 opacity-100',
|
|
armedDir === null && 'opacity-0'
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
export default SwipeCard |