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:
@@ -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) => (
|
||||||
|
|||||||
77
apps/web/src/hooks/usePullToRefresh.ts
Normal file
77
apps/web/src/hooks/usePullToRefresh.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user