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
e93922e4a0
commit
512a4aa448
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