import { Loader2, RefreshCw } from 'lucide-react' import { type CSSProperties, type MouseEvent as ReactMouseEvent, type ReactNode, type TouchEvent as ReactTouchEvent, type RefObject, useCallback, useRef, useState, } from 'react' import { cn } from '../lib/utils.js' const PULL_THRESHOLD = 64 const MAX_PULL_DISTANCE = 88 const AXIS_LOCK_DISTANCE = 8 const GESTURE_BLOCKING_TARGET = 'input, textarea, select, [contenteditable="true"], [role="slider"]' type Gesture = { startX: number startY: number axis: 'pending' | 'vertical' | 'cancelled' } export function PullToRefresh({ onRefresh, scrollRef, className, children, label = 'Refresh', }: { onRefresh: () => Promise scrollRef?: RefObject className?: string children: ReactNode label?: string }) { const rootRef = useRef(null) const gestureRef = useRef(null) const suppressClickRef = useRef(false) const distanceRef = useRef(0) const [distance, setDistance] = useState(0) const [refreshing, setRefreshing] = useState(false) const [status, setStatus] = useState('') const refresh = useCallback(async () => { if (refreshing) return setRefreshing(true) setStatus('Refreshing…') try { await onRefresh() setStatus('Updated') } catch { setStatus('Could not refresh. Check your connection, then try again.') } finally { setRefreshing(false) } }, [onRefresh, refreshing]) function resetGesture() { gestureRef.current = null distanceRef.current = 0 setDistance(0) } function onTouchStart(event: ReactTouchEvent) { suppressClickRef.current = false const touch = event.touches[0] const scroller = scrollRef?.current ?? rootRef.current const selection = window.getSelection?.()?.toString() const target = event.target as HTMLElement if ( refreshing || event.touches.length !== 1 || !touch || (scroller?.scrollTop ?? 0) > 0 || selection || target.closest(GESTURE_BLOCKING_TARGET) ) { gestureRef.current = null return } gestureRef.current = { startX: touch.clientX, startY: touch.clientY, axis: 'pending' } } function onTouchMove(event: ReactTouchEvent) { const gesture = gestureRef.current const touch = event.touches[0] if (!gesture || !touch || event.touches.length !== 1 || gesture.axis === 'cancelled') return const deltaX = touch.clientX - gesture.startX const deltaY = touch.clientY - gesture.startY if (gesture.axis === 'pending') { if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < AXIS_LOCK_DISTANCE) return if (Math.abs(deltaX) >= Math.abs(deltaY) || deltaY <= 0) { gesture.axis = 'cancelled' return } gesture.axis = 'vertical' suppressClickRef.current = true } if (deltaY <= 0) return if (event.cancelable) event.preventDefault() const nextDistance = Math.min(MAX_PULL_DISTANCE, deltaY * 0.5) distanceRef.current = nextDistance setDistance(nextDistance) } function onTouchEnd() { const shouldRefresh = distanceRef.current >= PULL_THRESHOLD resetGesture() window.setTimeout(() => { suppressClickRef.current = false }, 0) if (shouldRefresh) void refresh() } function onClickCapture(event: ReactMouseEvent) { if (!suppressClickRef.current) return suppressClickRef.current = false event.preventDefault() event.stopPropagation() } function onTouchCancel() { suppressClickRef.current = false resetGesture() } const armed = distance >= PULL_THRESHOLD return (
{children} {status}
) } export function RefreshButton({ onRefresh, refreshing, className, label = 'Refresh', }: { onRefresh: () => Promise | undefined refreshing?: boolean className?: string label?: string }) { const [pending, setPending] = useState(false) const [status, setStatus] = useState('') const busy = Boolean(refreshing || pending) async function handleRefresh() { setPending(true) setStatus('Refreshing…') try { await onRefresh() setStatus('Updated') } catch { setStatus('Could not refresh. Check your connection, then try again.') } finally { setPending(false) } } return ( <> {status} ) }