"use client"; import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; /** * SwipeableRow — gesture-driven row with reveal-on-swipe action shelves. * * Drag the row left to pull the right-side shelf into view, or drag * right to pull the left-side shelf into view. Past REVEAL_THRESHOLD * the shelf locks open; below the threshold it springs back closed. * Tapping outside an open row also closes it. * * ← drag left drag right → * ┌─────────────┐──┐ ┌──┌─────────────┐ * │ row body │R │ │ L│ row body │ * └─────────────┘──┘ └──└─────────────┘ * ↑ ↑ * Right shelf Left shelf * * The component is direction-agnostic — both shelves accept arbitrary * action buttons. Activity rows wire it as: * leftActions = Archive (right swipe → non-destructive lives here) * rightActions = Delete (left swipe → destructive lives here) * * Pointer events only — no third-party gesture lib — to keep the * bundle small and avoid SSR / hydration headaches. */ const REVEAL_THRESHOLD = 60; // px — how far you have to drag to lock shelf open const SHELF_WIDTH = 88; // px — width of each shelf (one button each side) interface SwipeableRowProps { /** Right-side shelf, revealed by swiping LEFT (offset goes negative). */ rightActions?: React.ReactNode; /** Left-side shelf, revealed by swiping RIGHT (offset goes positive). */ leftActions?: React.ReactNode; /** Row body — the visible content above the shelves. */ children: React.ReactNode; /** className for the OUTER wrapper (positioning, margins). */ className?: string; /** className for the inner sliding row (background, padding). */ rowClassName?: string; /** Override the default 88px right-shelf width. Use a multiple of * 88 when stacking multiple action buttons in the shelf. */ rightShelfWidth?: number; /** Override the default 88px left-shelf width. */ leftShelfWidth?: number; } export function SwipeableRow({ rightActions, leftActions, children, className, rowClassName, rightShelfWidth, leftShelfWidth, }: SwipeableRowProps) { const rightWidth = rightShelfWidth ?? SHELF_WIDTH; const leftWidth = leftShelfWidth ?? SHELF_WIDTH; // `offset` is the row's current x-translation in px: // 0 → closed // -SHELF_WIDTH → right shelf fully open // +SHELF_WIDTH → left shelf fully open const [offset, setOffset] = useState(0); const [dragging, setDragging] = useState(false); const containerRef = useRef(null); const dragStart = useRef<{ x: number; baseOffset: number } | null>(null); // Close the shelf when the user taps anywhere outside an open row. useEffect(() => { if (offset === 0) return; function onDocPointer(e: PointerEvent) { if (containerRef.current && !containerRef.current.contains(e.target as Node)) { setOffset(0); } } window.addEventListener("pointerdown", onDocPointer); return () => window.removeEventListener("pointerdown", onDocPointer); }, [offset]); function clamp(next: number): number { // Limit drags to the available shelf width on each side. const maxLeft = leftActions ? leftWidth : 0; const maxRight = rightActions ? rightWidth : 0; if (next > maxLeft) return maxLeft; if (next < -maxRight) return -maxRight; return next; } function handlePointerDown(e: React.PointerEvent) { if (e.button !== 0 && e.pointerType === "mouse") return; dragStart.current = { x: e.clientX, baseOffset: offset }; setDragging(true); } function handlePointerMove(e: React.PointerEvent) { if (!dragging || !dragStart.current) return; const dx = e.clientX - dragStart.current.x; setOffset(clamp(dragStart.current.baseOffset + dx)); } function handlePointerUp() { if (!dragging) return; setDragging(false); dragStart.current = null; setOffset((prev) => snapPosition(prev, { leftActions: !!leftActions, rightActions: !!rightActions, leftWidth, rightWidth, }), ); } return (
{/* Left shelf — pinned to the left, revealed by swipe-right. */} {leftActions && (
{leftActions}
)} {/* Right shelf — pinned to the right, revealed by swipe-left. */} {rightActions && (
= 0} className="absolute inset-y-0 right-0 flex items-stretch" style={{ width: rightWidth }} > {rightActions}
)} {/* Sliding row body. */}
{children}
); } /** * Pure helper: given the post-drag offset and which shelves exist, * decide what offset to snap to on release. */ export function snapPosition( offset: number, shelves: { leftActions: boolean; rightActions: boolean; leftWidth?: number; rightWidth?: number; }, ): number { const lw = shelves.leftWidth ?? SHELF_WIDTH; const rw = shelves.rightWidth ?? SHELF_WIDTH; if (offset >= REVEAL_THRESHOLD && shelves.leftActions) return lw; if (offset <= -REVEAL_THRESHOLD && shelves.rightActions) return -rw; return 0; } /** * Pure helper: compute the next dragOffset (clamped) and the * post-release snap target for a drag with delta `dx`. */ export function computeSwipeNext( baseOffset: number, dx: number, shelves: { leftActions: boolean; rightActions: boolean } = { leftActions: true, rightActions: true, }, ): { dragOffset: number; snapAfterRelease: number } { const maxLeft = shelves.leftActions ? SHELF_WIDTH : 0; const maxRight = shelves.rightActions ? SHELF_WIDTH : 0; const raw = baseOffset + dx; let clamped = raw > maxLeft ? maxLeft : raw < -maxRight ? -maxRight : raw; // Avoid `-0` slipping out when a side has no shelf (`-maxRight` is // `-0` when maxRight is 0). Normalize so call sites can `=== 0`. if (Object.is(clamped, -0)) clamped = 0; return { dragOffset: clamped, snapAfterRelease: snapPosition(clamped, shelves) }; } export const SWIPE_REVEAL_THRESHOLD = REVEAL_THRESHOLD; export const SWIPE_SHELF_WIDTH = SHELF_WIDTH;