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
This commit is contained in:
Nexus Dev 2026-04-02 02:06:07 +00:00
parent e93922e4a0
commit 512a4aa448
3 changed files with 196 additions and 0 deletions

View file

@ -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> | 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 (
<div ref={containerRef} className="relative h-full overflow-y-auto">
{/* Pull indicator */}
{isVisible && (
<div
className="absolute left-0 right-0 top-0 z-10 flex flex-col items-center justify-end pb-2"
style={{
height: isRefreshing ? 48 : Math.max(pullDistance, 0),
opacity: indicatorOpacity,
transform: isRefreshing ? undefined : `translateY(${indicatorTranslateY}px)`,
}}
aria-live="polite"
aria-label={
isRefreshing
? "Refreshing"
: pullDistance >= THRESHOLD
? "Release to refresh"
: "Pull to refresh"
}
>
<Loader2 className="h-6 w-6 animate-spin text-primary" />
{!isRefreshing && (
<span className="mt-1 text-xs text-muted-foreground">
{pullDistance >= THRESHOLD ? "Release to refresh" : "Pull to refresh"}
</span>
)}
</div>
)}
{/* Content — pushed down by pull distance when pulling */}
<div
style={{
transform:
pullDistance > 0
? `translateY(${Math.min(pullDistance, 96)}px)`
: undefined,
transition: pullDistance === 0 ? "transform 200ms ease-out" : undefined,
}}
>
{children}
</div>
</div>
);
}

View file

@ -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<boolean>(() => {
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;
}

View file

@ -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<HTMLDivElement | null>;
pullDistance: number;
isRefreshing: boolean;
setIsRefreshing: React.Dispatch<React.SetStateAction<boolean>>;
}
/**
* 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<HTMLDivElement | null>(null);
const startYRef = useRef<number>(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 };
}