102 lines
3.0 KiB
TypeScript
102 lines
3.0 KiB
TypeScript
|
|
import { useState, useCallback, useRef } from 'react'
|
||
|
|
import { cn } from '@/lib/utils'
|
||
|
|
import { RefreshCw } from 'lucide-react'
|
||
|
|
|
||
|
|
interface PullToRefreshProps {
|
||
|
|
onRefresh: () => Promise<void>
|
||
|
|
children: React.ReactNode
|
||
|
|
className?: string
|
||
|
|
}
|
||
|
|
|
||
|
|
export function PullToRefresh({ onRefresh, children, className }: PullToRefreshProps) {
|
||
|
|
const [pulling, setPulling] = useState(false)
|
||
|
|
const [pullDistance, setPullDistance] = useState(0)
|
||
|
|
const [refreshing, setRefreshing] = useState(false)
|
||
|
|
const touchStartY = useRef(0)
|
||
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
||
|
|
|
||
|
|
const maxPullDistance = 100
|
||
|
|
const refreshThreshold = 80
|
||
|
|
|
||
|
|
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||
|
|
// Only allow pull-to-refresh when at top of scroll
|
||
|
|
if (containerRef.current && containerRef.current.scrollTop === 0) {
|
||
|
|
touchStartY.current = e.targetTouches[0].clientY
|
||
|
|
setPulling(true)
|
||
|
|
}
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||
|
|
if (!pulling) return
|
||
|
|
|
||
|
|
const currentY = e.targetTouches[0].clientY
|
||
|
|
const diff = currentY - touchStartY.current
|
||
|
|
|
||
|
|
if (diff > 0) {
|
||
|
|
// Resistance increases as user pulls further
|
||
|
|
const resistance = 1 + (diff / maxPullDistance) * 0.5
|
||
|
|
const newDistance = Math.min(diff / resistance, maxPullDistance)
|
||
|
|
setPullDistance(newDistance)
|
||
|
|
}
|
||
|
|
}, [pulling])
|
||
|
|
|
||
|
|
const onTouchEnd = useCallback(async () => {
|
||
|
|
if (!pulling) return
|
||
|
|
|
||
|
|
if (pullDistance >= refreshThreshold && !refreshing) {
|
||
|
|
setRefreshing(true)
|
||
|
|
try {
|
||
|
|
await onRefresh()
|
||
|
|
} finally {
|
||
|
|
setRefreshing(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
setPulling(false)
|
||
|
|
setPullDistance(0)
|
||
|
|
}, [pulling, pullDistance, refreshing, onRefresh])
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={containerRef}
|
||
|
|
className={cn('relative overflow-y-auto', className)}
|
||
|
|
onTouchStart={onTouchStart}
|
||
|
|
onTouchMove={onTouchMove}
|
||
|
|
onTouchEnd={onTouchEnd}
|
||
|
|
>
|
||
|
|
{/* Pull indicator */}
|
||
|
|
<div
|
||
|
|
className="absolute top-0 left-0 right-0 flex items-center justify-center transition-transform"
|
||
|
|
style={{
|
||
|
|
transform: `translateY(${pullDistance - 60}px)`,
|
||
|
|
opacity: pulling ? 1 : 0,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div className="flex flex-col items-center gap-2">
|
||
|
|
<RefreshCw
|
||
|
|
size={24}
|
||
|
|
className={cn(
|
||
|
|
'text-primary transition-transform',
|
||
|
|
refreshing && 'animate-spin',
|
||
|
|
!refreshing && pullDistance >= refreshThreshold && 'rotate-180'
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
<span className="text-xs text-text-secondary">
|
||
|
|
{refreshing ? 'Refreshing...' : pullDistance >= refreshThreshold ? 'Release to refresh' : 'Pull to refresh'}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Content with offset when pulling */}
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
transform: pulling ? `translateY(${pullDistance}px)` : 'translateY(0)',
|
||
|
|
transition: pulling ? 'none' : 'transform 0.3s ease-out',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{children}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|