feat(mobile): pull-to-refresh sidebar list

- 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>
This commit is contained in:
2026-05-16 05:55:47 +00:00
parent 273eeac68c
commit 4b5b9b2cb3
2 changed files with 103 additions and 1 deletions

View File

@@ -0,0 +1,77 @@
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 };
}