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

@@ -22,6 +22,7 @@ import { api } from '@/api/client';
import { useSidebar } from '@/hooks/useSidebar'; import { useSidebar } from '@/hooks/useSidebar';
import { useSidebarDrawer } from '@/hooks/useSidebarDrawer'; import { useSidebarDrawer } from '@/hooks/useSidebarDrawer';
import { useViewport } from '@/hooks/useViewport'; import { useViewport } from '@/hooks/useViewport';
import { usePullToRefresh } from '@/hooks/usePullToRefresh';
import type { SidebarProject } from '@/api/types'; import type { SidebarProject } from '@/api/types';
import { giteaUrlFor } from '@/lib/projectUrls'; import { giteaUrlFor } from '@/lib/projectUrls';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -199,6 +200,7 @@ export function ProjectSidebar() {
const { open: drawerOpen } = useSidebarDrawer(); const { open: drawerOpen } = useSidebarDrawer();
const { isMobile } = useViewport(); const { isMobile } = useViewport();
const pull = usePullToRefresh(() => retry(), { enabled: isMobile });
// On mobile the sidebar is a slide-in drawer (fixed, z-40, off-screen by // On mobile the sidebar is a slide-in drawer (fixed, z-40, off-screen by
// default). On desktop it sits inline as a normal flex column. The // default). On desktop it sits inline as a normal flex column. The
@@ -223,7 +225,30 @@ export function ProjectSidebar() {
</Button> </Button>
</div> </div>
<nav className="flex-1 overflow-y-auto py-2"> {isMobile && (pull.pullDist > 0 || pull.refreshing) && (
<div
className="flex items-center justify-center text-[10px] uppercase tracking-wide text-muted-foreground border-b overflow-hidden shrink-0"
style={{
height: pull.refreshing ? 32 : Math.min(pull.pullDist, 80),
transition: pull.pullDist === 0 && !pull.refreshing ? 'height 0.2s ease' : undefined,
}}
aria-live="polite"
>
{pull.refreshing
? 'Refreshing…'
: pull.pullDist >= 80
? 'Release to refresh'
: 'Pull to refresh'}
</div>
)}
<nav
className="flex-1 overflow-y-auto py-2"
onTouchStart={isMobile ? pull.onTouchStart : undefined}
onTouchMove={isMobile ? pull.onTouchMove : undefined}
onTouchEnd={isMobile ? pull.onTouchEnd : undefined}
onTouchCancel={isMobile ? pull.onTouchEnd : undefined}
>
{loading && data == null && ( {loading && data == null && (
<div className="space-y-2 px-2"> <div className="space-y-2 px-2">
{[0, 1, 2, 3].map((i) => ( {[0, 1, 2, 3].map((i) => (

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 };
}