- Apply Climate Monitoring design system to all 81 files - Replace 931 hardcoded color references with CSS variables - Consistent theming: --color-primary, --color-text-*, --color-bg-* - Status colors: --color-error, --color-warning, --color-accent
100 lines
3.2 KiB
TypeScript
100 lines
3.2 KiB
TypeScript
import { useState, useRef, useCallback, ReactNode, TouchEvent } from 'react';
|
|
import { RefreshCw } from 'lucide-react';
|
|
|
|
interface PullToRefreshProps {
|
|
onRefresh: () => Promise<void>;
|
|
children: ReactNode;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
export function PullToRefresh({ onRefresh, children, disabled = false }: PullToRefreshProps) {
|
|
const [pulling, setPulling] = useState(false);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [pullDistance, setPullDistance] = useState(0);
|
|
|
|
const startY = useRef(0);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const THRESHOLD = 80; // pixels to pull before triggering refresh
|
|
const MAX_PULL = 120;
|
|
|
|
const handleTouchStart = useCallback((e: TouchEvent) => {
|
|
if (disabled || refreshing) return;
|
|
|
|
const scrollTop = containerRef.current?.scrollTop || 0;
|
|
if (scrollTop > 0) return; // Only trigger at top
|
|
|
|
startY.current = e.touches[0].clientY;
|
|
setPulling(true);
|
|
}, [disabled, refreshing]);
|
|
|
|
const handleTouchMove = useCallback((e: TouchEvent) => {
|
|
if (!pulling || refreshing) return;
|
|
|
|
const currentY = e.touches[0].clientY;
|
|
const diff = currentY - startY.current;
|
|
|
|
if (diff > 0) {
|
|
// Add resistance to pull
|
|
const resistance = Math.min(diff * 0.5, MAX_PULL);
|
|
setPullDistance(resistance);
|
|
}
|
|
}, [pulling, refreshing]);
|
|
|
|
const handleTouchEnd = useCallback(async () => {
|
|
if (!pulling) return;
|
|
|
|
setPulling(false);
|
|
|
|
if (pullDistance >= THRESHOLD && !refreshing) {
|
|
setRefreshing(true);
|
|
setPullDistance(THRESHOLD);
|
|
|
|
try {
|
|
await onRefresh();
|
|
} finally {
|
|
setRefreshing(false);
|
|
setPullDistance(0);
|
|
}
|
|
} else {
|
|
setPullDistance(0);
|
|
}
|
|
}, [pulling, pullDistance, refreshing, onRefresh]);
|
|
|
|
const progress = Math.min(pullDistance / THRESHOLD, 1);
|
|
const rotation = progress * 180;
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
className="relative overflow-auto h-full"
|
|
>
|
|
{/* Pull indicator */}
|
|
<div
|
|
className="absolute left-1/2 -translate-x-1/2 z-10 flex items-center justify-center transition-transform"
|
|
style={{
|
|
top: `${Math.max(-40, pullDistance - 40)}px`,
|
|
transform: `translateX(-50%) rotate(${rotation}deg)`,
|
|
opacity: progress
|
|
}}
|
|
>
|
|
<div className={`p-2 rounded-full bg-white dark:bg-slate-800 shadow-lg ${refreshing ? 'animate-spin' : ''}`}>
|
|
<RefreshCw size={20} className="text-[var(--color-primary)]" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content with pull transform */}
|
|
<div
|
|
style={{
|
|
transform: `translateY(${pullDistance}px)`,
|
|
transition: pulling ? 'none' : 'transform 0.2s ease-out'
|
|
}}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|