From 512a4aa448536aa2eb079dc9094ef9c982e3680f Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Thu, 2 Apr 2026 02:06:07 +0000 Subject: [PATCH] feat(26-02): create useMediaQuery hook, usePullToRefresh hook, and PullToRefresh component - useMediaQuery: SSR-safe hook with addEventListener for live breakpoint updates - usePullToRefresh: touch gesture hook with 64px threshold, haptic feedback via navigator.vibrate - PullToRefresh: visual wrapper with Loader2 spinner, pull/release text indicators --- ui/src/components/PullToRefresh.tsx | 81 +++++++++++++++++++++++++++ ui/src/hooks/useMediaQuery.ts | 29 ++++++++++ ui/src/hooks/usePullToRefresh.ts | 86 +++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 ui/src/components/PullToRefresh.tsx create mode 100644 ui/src/hooks/useMediaQuery.ts create mode 100644 ui/src/hooks/usePullToRefresh.ts diff --git a/ui/src/components/PullToRefresh.tsx b/ui/src/components/PullToRefresh.tsx new file mode 100644 index 00000000..1b6c3bbb --- /dev/null +++ b/ui/src/components/PullToRefresh.tsx @@ -0,0 +1,81 @@ +import { type ReactNode } from "react"; +import { Loader2 } from "lucide-react"; +import { usePullToRefresh } from "../hooks/usePullToRefresh"; + +const THRESHOLD = 64; + +interface PullToRefreshProps { + children: ReactNode; + onRefresh: () => Promise | void; + enabled?: boolean; +} + +/** + * Touch gesture wrapper that triggers a refresh after pulling down 64px. + * Shows a visual indicator with "Pull to refresh" / "Release to refresh" text. + */ +export function PullToRefresh({ children, onRefresh, enabled = true }: PullToRefreshProps) { + const { containerRef, pullDistance, isRefreshing, setIsRefreshing } = usePullToRefresh({ + onRefresh: () => { + const result = onRefresh(); + if (result instanceof Promise) { + result.finally(() => setIsRefreshing(false)); + } else { + setIsRefreshing(false); + } + }, + threshold: THRESHOLD, + maxPull: 96, + enabled, + }); + + const isVisible = pullDistance > 0 || isRefreshing; + const indicatorOpacity = isRefreshing ? 1 : pullDistance / THRESHOLD; + const indicatorTranslateY = isRefreshing + ? 0 + : Math.min(pullDistance - THRESHOLD / 2, 0); + + return ( +
+ {/* Pull indicator */} + {isVisible && ( +
= THRESHOLD + ? "Release to refresh" + : "Pull to refresh" + } + > + + {!isRefreshing && ( + + {pullDistance >= THRESHOLD ? "Release to refresh" : "Pull to refresh"} + + )} +
+ )} + + {/* Content — pushed down by pull distance when pulling */} +
0 + ? `translateY(${Math.min(pullDistance, 96)}px)` + : undefined, + transition: pullDistance === 0 ? "transform 200ms ease-out" : undefined, + }} + > + {children} +
+
+ ); +} diff --git a/ui/src/hooks/useMediaQuery.ts b/ui/src/hooks/useMediaQuery.ts new file mode 100644 index 00000000..dd6a9ef9 --- /dev/null +++ b/ui/src/hooks/useMediaQuery.ts @@ -0,0 +1,29 @@ +import { useEffect, useState } from "react"; + +/** + * Responsive breakpoint hook. Returns true when the media query matches. + * SSR-safe: defaults to false if window is undefined. + */ +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => { + if (typeof window === "undefined") return false; + return window.matchMedia(query).matches; + }); + + useEffect(() => { + if (typeof window === "undefined") return; + const mediaQueryList = window.matchMedia(query); + setMatches(mediaQueryList.matches); + + const handler = (event: MediaQueryListEvent) => { + setMatches(event.matches); + }; + + mediaQueryList.addEventListener("change", handler); + return () => { + mediaQueryList.removeEventListener("change", handler); + }; + }, [query]); + + return matches; +} diff --git a/ui/src/hooks/usePullToRefresh.ts b/ui/src/hooks/usePullToRefresh.ts new file mode 100644 index 00000000..8953c7dc --- /dev/null +++ b/ui/src/hooks/usePullToRefresh.ts @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +interface UsePullToRefreshOptions { + onRefresh: () => void; + threshold?: number; + maxPull?: number; + enabled?: boolean; +} + +interface UsePullToRefreshResult { + containerRef: React.RefObject; + pullDistance: number; + isRefreshing: boolean; + setIsRefreshing: React.Dispatch>; +} + +/** + * Touch gesture hook for pull-to-refresh functionality. + * Mirrors SwipeToArchive.tsx convention — native DOM events, not React synthetic. + */ +export function usePullToRefresh({ + onRefresh, + threshold = 64, + maxPull = 96, + enabled = true, +}: UsePullToRefreshOptions): UsePullToRefreshResult { + const containerRef = useRef(null); + const startYRef = useRef(0); + const [pullDistance, setPullDistance] = useState(0); + const [isRefreshing, setIsRefreshing] = useState(false); + + const handleTouchStart = useCallback( + (e: TouchEvent) => { + if (!enabled || isRefreshing) return; + // Only capture touch if we're at the top of the scroll container + if (containerRef.current?.scrollTop !== 0) return; + startYRef.current = e.touches[0]!.clientY; + }, + [enabled, isRefreshing], + ); + + const handleTouchMove = useCallback( + (e: TouchEvent) => { + if (!enabled || isRefreshing || startYRef.current === 0) return; + const dy = e.touches[0]!.clientY - startYRef.current; + if (dy > 0) { + setPullDistance(Math.min(dy, maxPull)); + } + }, + [enabled, isRefreshing, maxPull], + ); + + const handleTouchEnd = useCallback(() => { + if (!enabled || isRefreshing) return; + if (pullDistance >= threshold) { + // Haptic feedback if available + navigator.vibrate?.(10); + setIsRefreshing(true); + onRefresh(); + // Reset pull distance but keep isRefreshing until caller resolves + setPullDistance(0); + } else { + setPullDistance(0); + } + startYRef.current = 0; + }, [enabled, isRefreshing, pullDistance, threshold, onRefresh]); + + useEffect(() => { + const el = containerRef.current; + if (!el || !enabled) return; + + el.addEventListener("touchstart", handleTouchStart, { passive: true }); + el.addEventListener("touchmove", handleTouchMove, { passive: true }); + el.addEventListener("touchend", handleTouchEnd); + el.addEventListener("touchcancel", handleTouchEnd); + + return () => { + el.removeEventListener("touchstart", handleTouchStart); + el.removeEventListener("touchmove", handleTouchMove); + el.removeEventListener("touchend", handleTouchEnd); + el.removeEventListener("touchcancel", handleTouchEnd); + }; + }, [enabled, handleTouchStart, handleTouchMove, handleTouchEnd]); + + return { containerRef, pullDistance, isRefreshing, setIsRefreshing }; +}