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:
parent
a056ae6615
commit
a8cbc090fd
3 changed files with 196 additions and 0 deletions
81
ui/src/components/PullToRefresh.tsx
Normal file
81
ui/src/components/PullToRefresh.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
ui/src/hooks/useMediaQuery.ts
Normal file
29
ui/src/hooks/useMediaQuery.ts
Normal 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;
|
||||||
|
}
|
||||||
86
ui/src/hooks/usePullToRefresh.ts
Normal file
86
ui/src/hooks/usePullToRefresh.ts
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue