feat: PullToRefresh 풀다운 새로고침 컴포넌트
This commit is contained in:
86
src/components/PullToRefresh.css
Normal file
86
src/components/PullToRefresh.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/components/PullToRefresh.jsx
Normal file
99
src/components/PullToRefresh.jsx
Normal file
@@ -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 <div className={className}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const indicatorVisible = state !== 'idle';
|
||||||
|
const contentShift = pullY * CONTENT_SHIFT_FACTOR;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className={`pull-to-refresh ${className}`}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={handleTouchMove}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`pull-to-refresh__indicator ${indicatorVisible ? 'is-visible' : ''}`}
|
||||||
|
style={{ transform: `translateY(${pullY}px)` }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{state === 'refreshing' ? (
|
||||||
|
<span className="pull-to-refresh__spinner" />
|
||||||
|
) : (
|
||||||
|
<span className={`pull-to-refresh__arrow ${state === 'ready' ? 'is-ready' : ''}`}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M9 3v10M4 8l5 5 5-5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="pull-to-refresh__content"
|
||||||
|
style={{ transform: `translateY(${contentShift}px)`, transition: state === 'idle' ? 'transform 0.3s var(--ease-out)' : 'none' }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user