"use client"; import { animate, motion, useMotionValue, useReducedMotion, type PanInfo, } from "motion/react"; import { useCallback, useEffect, useRef, useState, type ReactNode, } from "react"; import { cn } from "@/lib/utils"; export type SwipeSide = "left" | "right"; export type SwipeableListValue = { id: string; side: SwipeSide; }; export type SwipeActionTone = | "neutral" | "primary" | "success" | "warning" | "danger"; export type SwipeAction = { id: string; label: ReactNode; icon: ReactNode; tone?: SwipeActionTone; disabled?: boolean; onClick?: (item: SwipeableListItem) => void; }; export type SwipeableListItem = { id: string; title?: ReactNode; description?: ReactNode; meta?: ReactNode; leading?: ReactNode; content?: ReactNode; leftActions?: SwipeAction[]; rightActions?: SwipeAction[]; disabled?: boolean; }; export type SwipeableListClassNames = { root?: string; item?: string; rail?: string; action?: string; surface?: string; leading?: string; content?: string; title?: string; description?: string; meta?: string; }; export interface SwipeableListProps { items: SwipeableListItem[]; value?: SwipeableListValue | null; defaultValue?: SwipeableListValue | null; onValueChange?: (value: SwipeableListValue | null) => void; onAction?: (payload: { item: SwipeableListItem; action: SwipeAction; side: SwipeSide; }) => void; actionWidth?: number; revealThreshold?: number; closeOnAction?: boolean; className?: string; classNames?: SwipeableListClassNames; renderItem?: (item: SwipeableListItem) => ReactNode; } // Distance-based release spring keeps short rebounds and full reveals feeling // equally direct, closer to native mobile list interactions. const ROW_SETTLE = { type: "spring", stiffness: 560, damping: 48, mass: 0.82, restDelta: 0.5, restSpeed: 8, } as const; const OPEN_DISTANCE_RATIO = 0.46; const CLOSE_DISTANCE_RATIO = 0.72; const OPEN_VELOCITY = 720; const CLOSE_VELOCITY = 320; const FLING_DISTANCE = 14; const RELEASE_VELOCITY_LIMIT = 1500; const ACTION_TONE_CLASS: Record = { neutral: "text-muted-foreground group-hover:text-foreground", primary: "text-foreground", success: "text-emerald-600 dark:text-emerald-400", warning: "text-amber-600 dark:text-amber-400", danger: "text-destructive", }; function useControllableSwipeValue({ value, defaultValue, onValueChange, }: { value?: SwipeableListValue | null; defaultValue?: SwipeableListValue | null; onValueChange?: (value: SwipeableListValue | null) => void; }) { const [internalValue, setInternalValue] = useState(defaultValue ?? null); const isControlled = value !== undefined; const currentValue = value ?? internalValue; const setValue = useCallback( (next: SwipeableListValue | null) => { if (!isControlled) { setInternalValue(next); } onValueChange?.(next); }, [isControlled, onValueChange], ); return [currentValue, setValue] as const; } function isActionableSide(value: number, sideWidth: number) { return sideWidth > 0 && Math.abs(value) > 0; } function clampReleaseVelocity(velocity: number) { return Math.max( -RELEASE_VELOCITY_LIMIT, Math.min(RELEASE_VELOCITY_LIMIT, velocity), ); } function SwipeActionButton({ action, actionWidth, side, focusable, onAction, className, }: { action: SwipeAction; actionWidth: number; side: SwipeSide; focusable: boolean; onAction: (action: SwipeAction, side: SwipeSide) => void; className?: string; }) { return ( ); } function SwipeableListRow({ item, actionWidth, revealThreshold, openValue, setOpenValue, closeOnAction, onAction, classNames, renderItem, }: { item: SwipeableListItem; actionWidth: number; revealThreshold: number; openValue: SwipeableListValue | null; setOpenValue: (value: SwipeableListValue | null) => void; closeOnAction: boolean; onAction?: SwipeableListProps["onAction"]; classNames?: SwipeableListClassNames; renderItem?: (item: SwipeableListItem) => ReactNode; }) { const reduce = useReducedMotion(); const x = useMotionValue(0); const animationRef = useRef<{ stop: () => void } | null>(null); const commandedTargetRef = useRef(0); const leftActions = item.leftActions ?? []; const rightActions = item.rightActions ?? []; const leftWidth = leftActions.length * actionWidth; const rightWidth = rightActions.length * actionWidth; const openSide = openValue?.id === item.id ? openValue.side : null; const targetX = openSide === "left" ? leftWidth : openSide === "right" ? -rightWidth : 0; const settleX = useCallback( (nextX: number, velocity = 0) => { commandedTargetRef.current = nextX; animationRef.current?.stop(); if (reduce) { x.set(nextX); return; } animationRef.current = animate(x, nextX, { ...ROW_SETTLE, velocity: clampReleaseVelocity(velocity), onComplete: () => x.set(nextX), }); }, [reduce, x], ); useEffect(() => { return () => animationRef.current?.stop(); }, []); useEffect(() => { if (commandedTargetRef.current === targetX) { return; } settleX(targetX); }, [settleX, targetX]); const getTargetX = useCallback( (side: SwipeSide | null) => side === "left" ? leftWidth : side === "right" ? -rightWidth : 0, [leftWidth, rightWidth], ); const snapTo = useCallback( (side: SwipeSide | null, velocity = 0) => { setOpenValue(side ? { id: item.id, side } : null); settleX(getTargetX(side), velocity); }, [getTargetX, item.id, setOpenValue, settleX], ); const onDragStart = useCallback(() => { animationRef.current?.stop(); if (openValue && openValue.id !== item.id) { setOpenValue(null); } }, [item.id, openValue, setOpenValue]); const onDragEnd = useCallback( (_: PointerEvent, info: PanInfo) => { const velocity = info.velocity.x; const latest = x.get(); const leftOpenThreshold = Math.max( revealThreshold, leftWidth * OPEN_DISTANCE_RATIO, ); const rightOpenThreshold = Math.max( revealThreshold, rightWidth * OPEN_DISTANCE_RATIO, ); if (openSide === "left") { if ( latest < leftWidth * CLOSE_DISTANCE_RATIO || velocity < -CLOSE_VELOCITY ) { snapTo(null, velocity); return; } snapTo("left", velocity); return; } if (openSide === "right") { if ( Math.abs(latest) < rightWidth * CLOSE_DISTANCE_RATIO || velocity > CLOSE_VELOCITY ) { snapTo(null, velocity); return; } snapTo("right", velocity); return; } if ( isActionableSide(latest, leftWidth) && (latest > leftOpenThreshold || (velocity > OPEN_VELOCITY && latest > FLING_DISTANCE)) ) { snapTo("left", velocity); return; } if ( isActionableSide(latest, rightWidth) && (latest < -rightOpenThreshold || (velocity < -OPEN_VELOCITY && latest < -FLING_DISTANCE)) ) { snapTo("right", velocity); return; } snapTo(null, velocity); }, [ leftWidth, openSide, revealThreshold, rightWidth, snapTo, x, ], ); const handleAction = useCallback( (action: SwipeAction, side: SwipeSide) => { action.onClick?.(item); onAction?.({ item, action, side }); if (closeOnAction) { snapTo(null); } }, [closeOnAction, item, onAction, snapTo], ); const defaultContent = (
{item.leading ? (
{item.leading}
) : null}
{item.title ? (
{item.title}
) : null} {item.description ? (
{item.description}
) : null}
{item.meta ? (
{item.meta}
) : null}
); return (
{leftActions.map((action) => ( ))}
{rightActions.map((action) => ( ))}
{renderItem ? renderItem(item) : item.content ?? defaultContent}
); } export function SwipeableList({ items, value, defaultValue = null, onValueChange, onAction, actionWidth = 56, revealThreshold = 34, closeOnAction = true, className, classNames, renderItem, }: SwipeableListProps) { const [openValue, setOpenValue] = useControllableSwipeValue({ value, defaultValue, onValueChange, }); return (
{items.map((item) => ( ))}
); }