iOS standalone PWAs don't get Safari's native pull-to-refresh, so add our own. New <PullToRefresh> component handles the gesture: dampened drag past an 80px threshold triggers an async onRefresh; below that it springs back. Swipe direction is locked to the first 6px of movement, so horizontal carousel swipes (landscape Home) don't accidentally fire PTR. The arrow icon rotates from 0° to 180° as the pull approaches the threshold and turns primary-color when ready; during refresh a CSS spinner replaces it. - HomeView refreshes the device list (and sync status with it) - LibraryView refreshes images, pending-share count, devices, and the active shared sub-tab page when it's the one in view Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<div
|
||||
class="ptr"
|
||||
@touchstart.passive="onTouchStart"
|
||||
@touchmove="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
@touchcancel="onTouchEnd"
|
||||
>
|
||||
<div
|
||||
class="ptr__indicator"
|
||||
:class="{ 'ptr__indicator--ready': progress >= 1 }"
|
||||
:style="{ opacity: indicatorOpacity, transform: `translateY(${ptrY * 0.6}px)` }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg v-if="!refreshing" class="ptr__arrow" viewBox="0 0 24 24"
|
||||
:style="{ transform: `rotate(${progress * 180}deg)` }">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" />
|
||||
<polyline points="6 13 12 19 18 13" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<div v-else class="ptr__spinner" role="status" aria-label="Refreshing" />
|
||||
</div>
|
||||
<div class="ptr__content" :style="contentStyle">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
/** Returns true when the consumer's scroll surface is at the top. */
|
||||
isAtTop?: () => boolean
|
||||
/** Async refresh action. Resolves when refresh is done. */
|
||||
onRefresh: () => Promise<unknown> | unknown
|
||||
/** Drag distance (px) needed to commit a refresh on release. */
|
||||
threshold?: number
|
||||
/** Visual cap on the drag (px) — pulls beyond this damp out. */
|
||||
maxPull?: number
|
||||
}>(), {
|
||||
threshold: 80,
|
||||
maxPull: 140,
|
||||
})
|
||||
|
||||
const ptrY = ref(0)
|
||||
const refreshing = ref(false)
|
||||
|
||||
let startY = 0
|
||||
let startX = 0
|
||||
let pulling = false
|
||||
let lockedAxis: 'x' | 'y' | null = null
|
||||
|
||||
const progress = computed(() => Math.min(ptrY.value / props.threshold, 1))
|
||||
|
||||
const indicatorOpacity = computed(() =>
|
||||
refreshing.value ? 1 : Math.min(ptrY.value / props.threshold, 1)
|
||||
)
|
||||
|
||||
const contentStyle = computed(() => ({
|
||||
transform: `translateY(${ptrY.value}px)`,
|
||||
transition: pulling ? 'none' : 'transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)',
|
||||
}))
|
||||
|
||||
function onTouchStart(e: TouchEvent) {
|
||||
if (refreshing.value) return
|
||||
if (props.isAtTop && !props.isAtTop()) return
|
||||
if (e.touches.length !== 1) return
|
||||
startY = e.touches[0].clientY
|
||||
startX = e.touches[0].clientX
|
||||
pulling = true
|
||||
lockedAxis = null
|
||||
}
|
||||
|
||||
function onTouchMove(e: TouchEvent) {
|
||||
if (!pulling) return
|
||||
|
||||
const dx = e.touches[0].clientX - startX
|
||||
const dy = e.touches[0].clientY - startY
|
||||
|
||||
// Decide axis on the first meaningful movement; if the user is swiping
|
||||
// horizontally (e.g., the landscape carousel), bail out of PTR entirely.
|
||||
if (lockedAxis === null) {
|
||||
if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return
|
||||
lockedAxis = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y'
|
||||
}
|
||||
if (lockedAxis === 'x' || dy <= 0) {
|
||||
pulling = false
|
||||
ptrY.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
// Re-check at-top on the way down too — if the surface isn't at the top
|
||||
// any more (e.g., user scrolled within a child during the gesture), stop.
|
||||
if (props.isAtTop && !props.isAtTop()) {
|
||||
pulling = false
|
||||
ptrY.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
// Damped pull: feels resistive past the threshold so it never feels free.
|
||||
const damped = dy < props.maxPull
|
||||
? dy * 0.5
|
||||
: props.maxPull * 0.5 + (dy - props.maxPull) * 0.1
|
||||
ptrY.value = Math.min(damped, props.maxPull * 0.7)
|
||||
|
||||
if (e.cancelable) e.preventDefault()
|
||||
}
|
||||
|
||||
async function onTouchEnd() {
|
||||
if (!pulling) return
|
||||
pulling = false
|
||||
|
||||
if (ptrY.value >= props.threshold) {
|
||||
refreshing.value = true
|
||||
ptrY.value = props.threshold * 0.7
|
||||
try {
|
||||
await props.onRefresh()
|
||||
} catch (e) {
|
||||
// Swallow — the consumer is responsible for surfacing the error
|
||||
// (e.g., via a toast). PTR's only job is to clear the spinner.
|
||||
console.error('Pull-to-refresh failed:', e)
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
ptrY.value = 0
|
||||
}
|
||||
} else {
|
||||
ptrY.value = 0
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.ptr {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.ptr__indicator {
|
||||
position: absolute;
|
||||
top: -56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
color: var(--color-text-muted);
|
||||
z-index: 1;
|
||||
|
||||
&--ready {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.ptr__arrow {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transition: transform 120ms linear, color 150ms ease;
|
||||
}
|
||||
|
||||
.ptr__spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 2.5px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: ptr-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes ptr-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.ptr__content {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
will-change: transform;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user