100 lines
2.9 KiB
JavaScript
100 lines
2.9 KiB
JavaScript
import { useRef, useState, useCallback } from 'react';
|
|
import { useIsMobile } from '../hooks/useIsMobile';
|
|
import './PullToRefresh.css';
|
|
|
|
const THRESHOLD = 60;
|
|
const MAX_PULL = 120;
|
|
const RESISTANCE = 0.5;
|
|
const CONTENT_SHIFT_FACTOR = 0.3;
|
|
|
|
export default function PullToRefresh({ onRefresh, children, className = '' }) {
|
|
const isMobile = useIsMobile();
|
|
const [pullY, setPullY] = useState(0);
|
|
const [state, setState] = useState('idle'); // idle | pulling | ready | refreshing
|
|
const startYRef = useRef(null);
|
|
const containerRef = useRef(null);
|
|
|
|
const handleTouchStart = useCallback((e) => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
if (el.scrollTop > 0) return; // only trigger at top
|
|
startYRef.current = e.touches[0].clientY;
|
|
}, []);
|
|
|
|
const handleTouchMove = useCallback((e) => {
|
|
if (startYRef.current === null) return;
|
|
const delta = e.touches[0].clientY - startYRef.current;
|
|
if (delta <= 0) {
|
|
setPullY(0);
|
|
setState('idle');
|
|
return;
|
|
}
|
|
const clamped = Math.min(delta * RESISTANCE, MAX_PULL);
|
|
setPullY(clamped);
|
|
setState(clamped >= THRESHOLD ? 'ready' : 'pulling');
|
|
}, []);
|
|
|
|
const handleTouchEnd = useCallback(async () => {
|
|
if (startYRef.current === null) return;
|
|
startYRef.current = null;
|
|
if (state === 'ready') {
|
|
setState('refreshing');
|
|
setPullY(THRESHOLD);
|
|
try {
|
|
await onRefresh?.();
|
|
} finally {
|
|
setState('idle');
|
|
setPullY(0);
|
|
}
|
|
} else {
|
|
setState('idle');
|
|
setPullY(0);
|
|
}
|
|
}, [state, onRefresh]);
|
|
|
|
if (!isMobile) {
|
|
return <div className={className}>{children}</div>;
|
|
}
|
|
|
|
const indicatorVisible = state !== 'idle';
|
|
const contentShift = pullY * CONTENT_SHIFT_FACTOR;
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className={`pull-to-refresh ${className}`}
|
|
onTouchStart={handleTouchStart}
|
|
onTouchMove={handleTouchMove}
|
|
onTouchEnd={handleTouchEnd}
|
|
>
|
|
<div
|
|
className={`pull-to-refresh__indicator ${indicatorVisible ? 'is-visible' : ''}`}
|
|
style={{ transform: `translateY(${pullY}px)` }}
|
|
aria-hidden="true"
|
|
>
|
|
{state === 'refreshing' ? (
|
|
<span className="pull-to-refresh__spinner" />
|
|
) : (
|
|
<span className={`pull-to-refresh__arrow ${state === 'ready' ? 'is-ready' : ''}`}>
|
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
|
<path
|
|
d="M9 3v10M4 8l5 5 5-5"
|
|
stroke="currentColor"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
/>
|
|
</svg>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div
|
|
className="pull-to-refresh__content"
|
|
style={{ transform: `translateY(${contentShift}px)`, transition: state === 'idle' ? 'transform 0.3s var(--ease-out)' : 'none' }}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|