From 1577f6363b783ceee0314aac389dcdf653afedc6 Mon Sep 17 00:00:00 2001 From: Tyler Date: Wed, 17 Jun 2026 14:45:08 -0600 Subject: [PATCH] feat(admin): add PullToRefresh (custom, prefers-reduced-motion aware) --- src/app/globals.css | 8 ++ src/components/admin/PullToRefresh.tsx | 128 +++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 src/components/admin/PullToRefresh.tsx diff --git a/src/app/globals.css b/src/app/globals.css index b65f4a3..cdab94a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -294,6 +294,14 @@ select:-webkit-autofill:focus { 100% { transform: translateX(400%); } } +/* Spinner for the PullToRefresh indicator. The indicator uses + * `border-top-color` to give the impression of one bright edge rotating. + * Reduced-motion users get `animation: none` from the component, so this + * keyframe doesn't need a motion-guard. */ +@keyframes spin { + to { transform: rotate(360deg); } +} + /* Reduce motion: respect the user's OS-level preference. */ /* The motion pass deliberately keeps the visual design intact (colors, type, layout) * but flattens everything that fights vestibular comfort. This block wipes: diff --git a/src/components/admin/PullToRefresh.tsx b/src/components/admin/PullToRefresh.tsx new file mode 100644 index 0000000..a47c91a --- /dev/null +++ b/src/components/admin/PullToRefresh.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { ReactNode, useEffect, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; + +interface PullToRefreshProps { + children: ReactNode; +} + +const THRESHOLD = 80; // px of pull distance to trigger refresh +const MAX_PULL = 140; // px before rubberbanding clamps +const REFRESH_INDICATOR_HEIGHT = 56; + +/** + * Custom pull-to-refresh — no third-party library. + * + * Only engages when the page is scrolled to the top (`window.scrollY === 0`). + * Uses rubberband resistance so the pull feels heavy past a comfortable + * range, and clamps the visible offset at MAX_PULL. The release animation + * snaps back to 0 (or to REFRESH_INDICATOR_HEIGHT if a refresh is in + * flight). Calls `router.refresh()` so the server component re-renders + * with fresh data. + * + * Respects `prefers-reduced-motion: reduce` — the transition is disabled + * and the offset still updates, but the snap-back doesn't animate. The + * indicator spinner animation is also dropped under reduced motion. + */ +export function PullToRefresh({ children }: PullToRefreshProps) { + const router = useRouter(); + const startY = useRef(null); + const pulling = useRef(false); + const [offset, setOffset] = useState(0); + const [refreshing, setRefreshing] = useState(false); + const [prefersReducedMotion, setPrefersReducedMotion] = useState(false); + + useEffect(() => { + const mq = window.matchMedia("(prefers-reduced-motion: reduce)"); + setPrefersReducedMotion(mq.matches); + const handler = (e: MediaQueryListEvent) => setPrefersReducedMotion(e.matches); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + + function onTouchStart(e: React.TouchEvent) { + if (refreshing) return; + // Only start a pull if the page is scrolled to the top — pulling + // mid-scroll would hijack normal scroll. + if (window.scrollY > 0) return; + startY.current = e.touches[0].clientY; + pulling.current = true; + } + + function onTouchMove(e: React.TouchEvent) { + if (!pulling.current || startY.current === null) return; + const dy = e.touches[0].clientY - startY.current; + if (dy <= 0) { + setOffset(0); + return; + } + // Rubberband: resistance curve so it gets harder to pull further. + const resistance = 1 - Math.min(1, dy / 400); + const next = Math.min(MAX_PULL, dy * resistance); + setOffset(next); + } + + async function onTouchEnd() { + if (!pulling.current) return; + pulling.current = false; + startY.current = null; + if (offset >= THRESHOLD) { + setRefreshing(true); + setOffset(REFRESH_INDICATOR_HEIGHT); + router.refresh(); + // Give the refresh a moment to complete visually. The actual data + // fetch happens on the server; this is just the "spinner shown + // for a beat" feel. + await new Promise((r) => setTimeout(r, 600)); + setRefreshing(false); + setOffset(0); + } else { + setOffset(0); + } + } + + return ( +
+ {offset > 0 && ( + + ); +} + +export default PullToRefresh;