From 2db0c1b3eb328eb5780cd525a7b85e4b3b384c62 Mon Sep 17 00:00:00 2001 From: gahusb Date: Thu, 23 Apr 2026 14:36:32 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20PullToRefresh=20=ED=92=80=EB=8B=A4?= =?UTF-8?q?=EC=9A=B4=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/PullToRefresh.css | 86 +++++++++++++++++++++++++++ src/components/PullToRefresh.jsx | 99 ++++++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+) create mode 100644 src/components/PullToRefresh.css create mode 100644 src/components/PullToRefresh.jsx diff --git a/src/components/PullToRefresh.css b/src/components/PullToRefresh.css new file mode 100644 index 0000000..e59e1df --- /dev/null +++ b/src/components/PullToRefresh.css @@ -0,0 +1,86 @@ +/* PullToRefresh — pull-down-to-refresh wrapper */ + +.pull-to-refresh { + position: relative; + overscroll-behavior-y: contain; +} + +/* Indicator circle */ +.pull-to-refresh__indicator { + position: absolute; + top: -48px; + left: 50%; + transform: translateX(-50%); + width: 36px; + height: 36px; + border-radius: 50%; + background: var(--surface-card); + border: 1px solid var(--border-line); + box-shadow: var(--shadow-md); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + pointer-events: none; + transition: opacity 0.18s var(--ease-out); + z-index: 10; + color: var(--neon-cyan); +} + +.pull-to-refresh__indicator.is-visible { + opacity: 1; +} + +/* Spinner */ +.pull-to-refresh__spinner { + display: block; + width: 20px; + height: 20px; + border: 2px solid var(--border-line); + border-top-color: var(--neon-cyan); + border-radius: 50%; + animation: ptr-spin 0.7s linear infinite; +} + +@keyframes ptr-spin { + to { transform: rotate(360deg); } +} + +/* Arrow chevron */ +.pull-to-refresh__arrow { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + transition: transform 0.2s var(--ease-out); +} + +.pull-to-refresh__arrow.is-ready { + transform: rotate(180deg); +} + +/* Content area */ +.pull-to-refresh__content { + will-change: transform; +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .pull-to-refresh__spinner { + animation: none; + border-top-color: var(--neon-cyan); + } + + .pull-to-refresh__arrow { + transition: none; + } + + .pull-to-refresh__indicator { + transition: none; + } + + .pull-to-refresh__content { + transition: none !important; + } +} diff --git a/src/components/PullToRefresh.jsx b/src/components/PullToRefresh.jsx new file mode 100644 index 0000000..4525e27 --- /dev/null +++ b/src/components/PullToRefresh.jsx @@ -0,0 +1,99 @@ +import { useRef, useState, useCallback } from 'react'; +import { useIsMobile } from '../hooks/useIsMobile'; +import './PullToRefresh.css'; + +const THRESHOLD = 60; +const MAX_PULL = 120; +const RESISTANCE = 0.5; +const CONTENT_SHIFT_FACTOR = 0.3; + +export default function PullToRefresh({ onRefresh, children, className = '' }) { + const isMobile = useIsMobile(); + const [pullY, setPullY] = useState(0); + const [state, setState] = useState('idle'); // idle | pulling | ready | refreshing + const startYRef = useRef(null); + const containerRef = useRef(null); + + const handleTouchStart = useCallback((e) => { + const el = containerRef.current; + if (!el) return; + if (el.scrollTop > 0) return; // only trigger at top + startYRef.current = e.touches[0].clientY; + }, []); + + const handleTouchMove = useCallback((e) => { + if (startYRef.current === null) return; + const delta = e.touches[0].clientY - startYRef.current; + if (delta <= 0) { + setPullY(0); + setState('idle'); + return; + } + const clamped = Math.min(delta * RESISTANCE, MAX_PULL); + setPullY(clamped); + setState(clamped >= THRESHOLD ? 'ready' : 'pulling'); + }, []); + + const handleTouchEnd = useCallback(async () => { + if (startYRef.current === null) return; + startYRef.current = null; + if (state === 'ready') { + setState('refreshing'); + setPullY(THRESHOLD); + try { + await onRefresh?.(); + } finally { + setState('idle'); + setPullY(0); + } + } else { + setState('idle'); + setPullY(0); + } + }, [state, onRefresh]); + + if (!isMobile) { + return
{children}
; + } + + const indicatorVisible = state !== 'idle'; + const contentShift = pullY * CONTENT_SHIFT_FACTOR; + + return ( +
+ +
+ {children} +
+
+ ); +}