- usePullToRefresh: hand-rolled hook. Records startY only when the scroll container is at scrollTop=0 to avoid hijacking mid-scroll pulls. Tracks downward delta on touchmove; fires onRefresh on touchend if delta >= 80px threshold. Holds the refreshing state for 600ms minimum so the action feels intentional. - ProjectSidebar: wires usePullToRefresh(() => retry()) on the nav element, mobile-only. A status indicator above the nav grows with pullDist (max 80px) and cycles 'Pull to refresh' -> 'Release to refresh' -> 'Refreshing...'. retry() is from useSidebar and refetches GET /api/sidebar. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
78 lines
2.3 KiB
TypeScript
78 lines
2.3 KiB
TypeScript
import { useCallback, useRef, useState } from 'react';
|
|
import type { TouchEvent } from 'react';
|
|
|
|
interface Options {
|
|
threshold?: number;
|
|
enabled?: boolean;
|
|
maxPull?: number;
|
|
}
|
|
|
|
interface Handlers {
|
|
onTouchStart: (e: TouchEvent<HTMLElement>) => void;
|
|
onTouchMove: (e: TouchEvent<HTMLElement>) => void;
|
|
onTouchEnd: () => void;
|
|
pullDist: number;
|
|
refreshing: boolean;
|
|
}
|
|
|
|
// Hand-rolled pull-to-refresh: records the initial Y on touchstart only if
|
|
// the target is scrolled to the top, then tracks downward pull on touchmove.
|
|
// On touchend, fires onRefresh if the pull exceeded the threshold.
|
|
export function usePullToRefresh(
|
|
onRefresh: () => void | Promise<void>,
|
|
{ threshold = 80, enabled = true, maxPull = 120 }: Options = {},
|
|
): Handlers {
|
|
const [pullDist, setPullDist] = useState(0);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const startYRef = useRef<number | null>(null);
|
|
|
|
const onTouchStart = useCallback(
|
|
(e: TouchEvent<HTMLElement>) => {
|
|
if (!enabled || refreshing) return;
|
|
const target = e.currentTarget as HTMLElement;
|
|
if (target.scrollTop > 0) return;
|
|
const t = e.touches[0];
|
|
if (!t) return;
|
|
startYRef.current = t.clientY;
|
|
},
|
|
[enabled, refreshing],
|
|
);
|
|
|
|
const onTouchMove = useCallback(
|
|
(e: TouchEvent<HTMLElement>) => {
|
|
if (!enabled || refreshing || startYRef.current === null) return;
|
|
const t = e.touches[0];
|
|
if (!t) return;
|
|
const delta = t.clientY - startYRef.current;
|
|
if (delta > 0) {
|
|
setPullDist(Math.min(delta, maxPull));
|
|
} else {
|
|
setPullDist(0);
|
|
}
|
|
},
|
|
[enabled, refreshing, maxPull],
|
|
);
|
|
|
|
const onTouchEnd = useCallback(() => {
|
|
if (!enabled || refreshing) {
|
|
startYRef.current = null;
|
|
setPullDist(0);
|
|
return;
|
|
}
|
|
const fired = pullDist >= threshold && startYRef.current !== null;
|
|
startYRef.current = null;
|
|
setPullDist(0);
|
|
if (fired) {
|
|
setRefreshing(true);
|
|
Promise.resolve(onRefresh())
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
// Hold the indicator briefly so the action feels intentional.
|
|
window.setTimeout(() => setRefreshing(false), 600);
|
|
});
|
|
}
|
|
}, [enabled, refreshing, pullDist, threshold, onRefresh]);
|
|
|
|
return { onTouchStart, onTouchMove, onTouchEnd, pullDist, refreshing };
|
|
}
|