feat: pull-to-refresh on Home and Library
CI / test (push) Has been cancelled

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:
2026-05-06 19:09:52 -04:00
parent ca4595873d
commit 328ad632d3
24 changed files with 419 additions and 83 deletions
+186
View File
@@ -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>