| <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> |