|
<script lang="ts"> |
|
import { onMount } from 'svelte'; |
|
|
|
interface Props { |
|
onRefresh: () => Promise<void>; |
|
threshold?: number; |
|
children?: any; |
|
} |
|
|
|
let { onRefresh, threshold = 80, children }: Props = $props(); |
|
|
|
let container: HTMLDivElement; |
|
let startY = 0; |
|
let currentY = 0; |
|
let pulling = $state(false); |
|
let releasing = $state(false); |
|
let refreshing = $state(false); |
|
let pullDistance = $state(0); |
|
|
|
let pullProgress = $derived(Math.min(pullDistance / threshold, 1)); |
|
let canRefresh = $derived(pullProgress >= 1 && !refreshing); |
|
|
|
function handleTouchStart(e: TouchEvent) { |
|
if (container.scrollTop !== 0 || refreshing) return; |
|
|
|
startY = e.touches[0].clientY; |
|
pulling = true; |
|
} |
|
|
|
function handleTouchMove(e: TouchEvent) { |
|
if (!pulling || refreshing) return; |
|
|
|
currentY = e.touches[0].clientY; |
|
const diff = currentY - startY; |
|
|
|
if (diff > 0 && container.scrollTop === 0) { |
|
e.preventDefault(); |
|
pullDistance = diff * 0.5; |
|
} |
|
} |
|
|
|
async function handleTouchEnd() { |
|
if (!pulling) return; |
|
|
|
pulling = false; |
|
|
|
if (canRefresh) { |
|
releasing = true; |
|
refreshing = true; |
|
|
|
try { |
|
await onRefresh(); |
|
} finally { |
|
refreshing = false; |
|
setTimeout(() => { |
|
releasing = false; |
|
pullDistance = 0; |
|
}, 300); |
|
} |
|
} else { |
|
releasing = true; |
|
setTimeout(() => { |
|
releasing = false; |
|
pullDistance = 0; |
|
}, 300); |
|
} |
|
} |
|
|
|
onMount(() => { |
|
|
|
function handleMouseDown(e: MouseEvent) { |
|
if (container.scrollTop !== 0 || refreshing) return; |
|
startY = e.clientY; |
|
pulling = true; |
|
} |
|
|
|
function handleMouseMove(e: MouseEvent) { |
|
if (!pulling || refreshing) return; |
|
|
|
currentY = e.clientY; |
|
const diff = currentY - startY; |
|
|
|
if (diff > 0 && container.scrollTop === 0) { |
|
e.preventDefault(); |
|
pullDistance = diff * 0.5; |
|
} |
|
} |
|
|
|
function handleMouseUp() { |
|
handleTouchEnd(); |
|
} |
|
|
|
container.addEventListener('mousedown', handleMouseDown); |
|
window.addEventListener('mousemove', handleMouseMove); |
|
window.addEventListener('mouseup', handleMouseUp); |
|
|
|
return () => { |
|
container?.removeEventListener('mousedown', handleMouseDown); |
|
window.removeEventListener('mousemove', handleMouseMove); |
|
window.removeEventListener('mouseup', handleMouseUp); |
|
}; |
|
}); |
|
</script> |
|
|
|
<div class="pull-to-refresh-container"> |
|
<div |
|
class="pull-indicator" |
|
class:pulling |
|
class:releasing |
|
class:refreshing |
|
style="transform: translateY({refreshing ? threshold : pullDistance}px)" |
|
> |
|
<div class="spinner-container" style="transform: rotate({pullProgress * 180}deg)"> |
|
{#if refreshing} |
|
<div class="spinner"></div> |
|
{:else} |
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> |
|
<path d="M12 2v4m0 12v4M4.93 4.93l2.83 2.83m8.48 8.48l2.83 2.83M2 12h4m12 0h4M4.93 19.07l2.83-2.83m8.48-8.48l2.83-2.83" |
|
opacity={pullProgress} /> |
|
</svg> |
|
{/if} |
|
</div> |
|
<div class="pull-text"> |
|
{#if refreshing} |
|
Refreshing... |
|
{:else if canRefresh} |
|
Release to refresh |
|
{:else} |
|
Pull to refresh |
|
{/if} |
|
</div> |
|
</div> |
|
|
|
<div |
|
bind:this={container} |
|
class="content-container" |
|
class:pulling |
|
ontouchstart={handleTouchStart} |
|
ontouchmove={handleTouchMove} |
|
ontouchend={handleTouchEnd} |
|
style="transform: translateY({refreshing ? threshold : pullDistance}px)" |
|
> |
|
{@render children?.()} |
|
</div> |
|
</div> |
|
|
|
<style> |
|
.pull-to-refresh-container { |
|
position: relative; |
|
height: 100%; |
|
overflow: hidden; |
|
} |
|
|
|
.content-container { |
|
height: 100%; |
|
overflow-y: auto; |
|
-webkit-overflow-scrolling: touch; |
|
transition: transform 0.3s ease; |
|
} |
|
|
|
.content-container.pulling { |
|
transition: none; |
|
} |
|
|
|
.pull-indicator { |
|
position: absolute; |
|
top: -60px; |
|
left: 0; |
|
right: 0; |
|
height: 60px; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
gap: 12px; |
|
transition: transform 0.3s ease; |
|
z-index: 10; |
|
} |
|
|
|
.pull-indicator.pulling { |
|
transition: none; |
|
} |
|
|
|
.spinner-container { |
|
transition: transform 0.2s ease; |
|
} |
|
|
|
.spinner { |
|
width: 24px; |
|
height: 24px; |
|
border: 2px solid #e0e0e0; |
|
border-top-color: #007bff; |
|
border-radius: 50%; |
|
animation: spin 0.8s linear infinite; |
|
} |
|
|
|
@keyframes spin { |
|
to { transform: rotate(360deg); } |
|
} |
|
|
|
.pull-text { |
|
font-size: 14px; |
|
color: #666; |
|
white-space: nowrap; |
|
} |
|
</style> |