Spaces:
Sleeping
Sleeping
File size: 4,444 Bytes
bea55e2 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 | 'use client';
import { useState } from 'react';
import { useDrag } from '@use-gesture/react';
import { useSpring, animated } from '@react-spring/web';
import { Trash2, Archive, Check } from 'lucide-react';
/**
* SwipeableRow — Telegram-style swipeable list item.
*
* Swipe left to reveal action buttons (delete, archive, etc.)
* Works on both iOS and Android with physics-based spring animation.
*
* Usage:
* <SwipeableRow onDelete={() => removeItem(id)}>
* <div>Item content here</div>
* </SwipeableRow>
*
* Props:
* - onDelete: called when the delete action is triggered
* - onArchive: optional, called when archive action is triggered
* - threshold: drag distance to trigger action (default: 120px)
*/
export function SwipeableRow({
children,
onDelete,
onArchive,
threshold = 120,
}: {
children: React.ReactNode;
onDelete?: () => void;
onArchive?: () => void;
threshold?: number;
}) {
const [{ x }, api] = useSpring(() => ({ x: 0 }));
const [showActions, setShowActions] = useState(false);
const bind = useDrag(
({ down, movement: [mx], direction: [dx], velocity: [vx] }) => {
// Only allow leftward drag (negative x) to reveal actions
const clamped = Math.max(0, -mx); // 0 to threshold
const maxDrag = onArchive ? 160 : 80; // wider if 2 actions
if (down) {
// While dragging: follow finger with slight resistance
api.start({ x: Math.max(-maxDrag, -clamped), immediate: true });
} else {
// Released: snap to open or closed based on threshold
if (clamped > threshold || (vx > 0.5 && dx < 0)) {
// Snap open to reveal actions
api.start({ x: -maxDrag });
setShowActions(true);
} else {
// Snap closed
api.start({ x: 0 });
setShowActions(false);
}
}
},
{ axis: 'x', filterTaps: true }
);
const handleClose = () => {
api.start({ x: 0 });
setShowActions(false);
};
const handleDelete = () => {
api.start({ x: -500, immediate: true });
setTimeout(() => {
onDelete?.();
handleClose();
}, 200);
};
const handleArchive = () => {
onArchive?.();
handleClose();
};
return (
<div className="relative overflow-hidden">
{/* Action buttons behind the row */}
<div className="absolute inset-0 flex justify-end">
{onArchive && (
<button
onClick={handleArchive}
className="w-20 h-full bg-neutral-500 flex items-center justify-center"
aria-label="Archive"
>
<Archive className="w-5 h-5 text-white" />
</button>
)}
{onDelete && (
<button
onClick={handleDelete}
className={`h-full flex items-center justify-center ${onArchive ? 'w-20 bg-[#ed4956]' : 'w-20 bg-[#ed4956]'}`}
aria-label="Delete"
style={{ width: onArchive ? 80 : 80 }}
>
<Trash2 className="w-5 h-5 text-white" />
</button>
)}
</div>
{/* The draggable row content */}
<animated.div
{...bind()}
style={{ x, touchAction: 'pan-y' }}
className="relative bg-white"
>
{children}
</animated.div>
</div>
);
}
/**
* SwipeableCard — a simpler variant for cards that can be swiped away.
* When swiped past the threshold, the card animates off-screen and
* calls onDismiss.
*/
export function SwipeableCard({
children,
onDismiss,
}: {
children: React.ReactNode;
onDismiss?: () => void;
}) {
const [{ x, opacity }, api] = useSpring(() => ({ x: 0, opacity: 1 }));
const bind = useDrag(
({ down, movement: [mx], direction: [dx], velocity: [vx] }) => {
if (down) {
api.start({ x: mx, opacity: 1 - Math.min(Math.abs(mx) / 300, 0.5), immediate: true });
} else {
// If dragged far enough or flicked, dismiss
if (Math.abs(mx) > 150 || (vx > 0.5 && Math.abs(mx) > 50)) {
const direction = mx > 0 ? 1 : -1;
api.start({ x: direction * 500, opacity: 0 });
setTimeout(() => onDismiss?.(), 300);
} else {
// Snap back
api.start({ x: 0, opacity: 1 });
}
}
},
{ axis: 'x' }
);
return (
<animated.div
{...bind()}
style={{ x, opacity, touchAction: 'pan-y' }}
>
{children}
</animated.div>
);
}
|