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>
@@ -0,0 +1,113 @@
import { describe, it, expect, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import PullToRefresh from '@/components/PullToRefresh.vue'
function touchEvent(type: string, x: number, y: number) {
const e = new Event(type, { bubbles: true, cancelable: true }) as TouchEvent
Object.defineProperty(e, 'touches', { value: [{ clientX: x, clientY: y }] })
Object.defineProperty(e, 'changedTouches', { value: [{ clientX: x, clientY: y }] })
return e
}
describe('PullToRefresh', () => {
it('renders an arrow icon by default and the slot content', () => {
const wrapper = mount(PullToRefresh, {
props: { onRefresh: () => Promise.resolve() },
slots: { default: '<p class="content">hello</p>' },
})
expect(wrapper.find('.ptr__arrow').exists()).toBe(true)
expect(wrapper.find('.ptr__spinner').exists()).toBe(false)
expect(wrapper.find('.content').exists()).toBe(true)
})
it('triggers onRefresh when the user drags past the threshold and releases', async () => {
const onRefresh = vi.fn(() => Promise.resolve())
const wrapper = mount(PullToRefresh, {
props: { onRefresh, threshold: 50 },
})
const root = wrapper.find('.ptr').element as HTMLElement
root.dispatchEvent(touchEvent('touchstart', 100, 100))
root.dispatchEvent(touchEvent('touchmove', 100, 250)) // dy=150, damped=75 (>50)
root.dispatchEvent(touchEvent('touchend', 100, 250))
await flushPromises()
expect(onRefresh).toHaveBeenCalledTimes(1)
})
it('does not trigger onRefresh when the drag is below the threshold', async () => {
const onRefresh = vi.fn(() => Promise.resolve())
const wrapper = mount(PullToRefresh, {
props: { onRefresh, threshold: 80 },
})
const root = wrapper.find('.ptr').element as HTMLElement
root.dispatchEvent(touchEvent('touchstart', 100, 100))
root.dispatchEvent(touchEvent('touchmove', 100, 140)) // dy=40, damped=20
root.dispatchEvent(touchEvent('touchend', 100, 140))
await flushPromises()
expect(onRefresh).not.toHaveBeenCalled()
})
it('does not trigger onRefresh when isAtTop returns false', async () => {
const onRefresh = vi.fn(() => Promise.resolve())
const wrapper = mount(PullToRefresh, {
props: { onRefresh, threshold: 50, isAtTop: () => false },
})
const root = wrapper.find('.ptr').element as HTMLElement
root.dispatchEvent(touchEvent('touchstart', 100, 100))
root.dispatchEvent(touchEvent('touchmove', 100, 300))
root.dispatchEvent(touchEvent('touchend', 100, 300))
await flushPromises()
expect(onRefresh).not.toHaveBeenCalled()
})
it('bails when the user swipes horizontally instead of vertically', async () => {
const onRefresh = vi.fn(() => Promise.resolve())
const wrapper = mount(PullToRefresh, {
props: { onRefresh, threshold: 50 },
})
const root = wrapper.find('.ptr').element as HTMLElement
root.dispatchEvent(touchEvent('touchstart', 100, 100))
root.dispatchEvent(touchEvent('touchmove', 300, 105)) // big dx, tiny dy
root.dispatchEvent(touchEvent('touchmove', 300, 200)) // user keeps moving — should still bail
root.dispatchEvent(touchEvent('touchend', 300, 200))
await flushPromises()
expect(onRefresh).not.toHaveBeenCalled()
})
it('shows the spinner while onRefresh is pending and clears it when resolved', async () => {
let resolveRefresh!: () => void
const onRefresh = vi.fn(() => new Promise<void>(r => { resolveRefresh = r }))
const wrapper = mount(PullToRefresh, {
props: { onRefresh, threshold: 50 },
})
const root = wrapper.find('.ptr').element as HTMLElement
root.dispatchEvent(touchEvent('touchstart', 100, 100))
root.dispatchEvent(touchEvent('touchmove', 100, 250))
root.dispatchEvent(touchEvent('touchend', 100, 250))
await wrapper.vm.$nextTick()
expect(wrapper.find('.ptr__spinner').exists()).toBe(true)
expect(wrapper.find('.ptr__arrow').exists()).toBe(false)
resolveRefresh()
await flushPromises()
expect(wrapper.find('.ptr__spinner').exists()).toBe(false)
expect(wrapper.find('.ptr__arrow').exists()).toBe(true)
})
it('keeps the spinner up while onRefresh rejects, then clears it', async () => {
let rejectRefresh!: (e: unknown) => void
const onRefresh = vi.fn(() => new Promise<void>((_, rej) => { rejectRefresh = rej }))
const wrapper = mount(PullToRefresh, {
props: { onRefresh, threshold: 50 },
})
const root = wrapper.find('.ptr').element as HTMLElement
root.dispatchEvent(touchEvent('touchstart', 100, 100))
root.dispatchEvent(touchEvent('touchmove', 100, 250))
root.dispatchEvent(touchEvent('touchend', 100, 250))
await wrapper.vm.$nextTick()
expect(wrapper.find('.ptr__spinner').exists()).toBe(true)
rejectRefresh(new Error('boom'))
await flushPromises()
expect(wrapper.find('.ptr__spinner').exists()).toBe(false)
})
})
+73 -56
View File
@@ -1,70 +1,73 @@
<template>
<main class="home-view">
<!-- Loading -->
<div v-if="devicesStore.loading" class="home-view__loading" aria-live="polite">
Loading
</div>
<!-- Empty state -->
<div v-else-if="devicesStore.devices.length === 0" class="home-view__empty">
<div class="home-view__empty-card">
<svg class="home-view__empty-icon" width="48" height="48" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21,15 16,10 5,21"/>
</svg>
<p class="home-view__empty-title">Set up your first frame</p>
<p class="home-view__empty-sub">
Power on your pictureFrame device and scan the QR code it displays to get started.
</p>
<PullToRefresh :is-at-top="isAtTop" :on-refresh="refreshDevices">
<!-- Loading -->
<div v-if="devicesStore.loading" class="home-view__loading" aria-live="polite">
Loading
</div>
</div>
<!-- Single device large card -->
<div v-else-if="devicesStore.devices.length === 1" class="home-view__single">
<FrameCard
:deviceId="devicesStore.devices[0].id"
:name="devicesStore.devices[0].name"
size="large"
:status="deviceStatus(devicesStore.devices[0])"
:orientation="devicesStore.devices[0].orientation"
:thumbnailUrl="previewUrl(devicesStore.devices[0])"
:lastSync="lastSyncLabel(devicesStore.devices[0])"
:nextSync="nextSyncLabel(devicesStore.devices[0])"
@add-photo="onAddPhoto"
@edit="onEdit"
/>
</div>
<!-- Empty state -->
<div v-else-if="devicesStore.devices.length === 0" class="home-view__empty">
<div class="home-view__empty-card">
<svg class="home-view__empty-icon" width="48" height="48" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21,15 16,10 5,21"/>
</svg>
<p class="home-view__empty-title">Set up your first frame</p>
<p class="home-view__empty-sub">
Power on your pictureFrame device and scan the QR code it displays to get started.
</p>
</div>
</div>
<!-- Multiple devices vertical scroll-snap stack of large cards -->
<div
v-else
class="home-view__stack"
role="list"
aria-label="Frames"
>
<div
v-for="device in devicesStore.devices"
:key="device.id"
class="home-view__slide"
role="listitem"
:aria-label="device.name"
>
<!-- Single device large card -->
<div v-else-if="devicesStore.devices.length === 1" class="home-view__single">
<FrameCard
:deviceId="device.id"
:name="device.name"
:deviceId="devicesStore.devices[0].id"
:name="devicesStore.devices[0].name"
size="large"
:status="deviceStatus(device)"
:orientation="device.orientation"
:thumbnailUrl="previewUrl(device)"
:lastSync="lastSyncLabel(device)"
:nextSync="nextSyncLabel(device)"
:status="deviceStatus(devicesStore.devices[0])"
:orientation="devicesStore.devices[0].orientation"
:thumbnailUrl="previewUrl(devicesStore.devices[0])"
:lastSync="lastSyncLabel(devicesStore.devices[0])"
:nextSync="nextSyncLabel(devicesStore.devices[0])"
@add-photo="onAddPhoto"
@edit="onEdit"
/>
</div>
</div>
<!-- Multiple devices vertical scroll-snap stack of large cards -->
<div
v-else
ref="stackEl"
class="home-view__stack"
role="list"
aria-label="Frames"
>
<div
v-for="device in devicesStore.devices"
:key="device.id"
class="home-view__slide"
role="listitem"
:aria-label="device.name"
>
<FrameCard
:deviceId="device.id"
:name="device.name"
size="large"
:status="deviceStatus(device)"
:orientation="device.orientation"
:thumbnailUrl="previewUrl(device)"
:lastSync="lastSyncLabel(device)"
:nextSync="nextSyncLabel(device)"
@add-photo="onAddPhoto"
@edit="onEdit"
/>
</div>
</div>
</PullToRefresh>
</main>
<!-- Frame settings sheet -->
@@ -183,6 +186,7 @@ import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import BaseInput from '@/components/BaseInput.vue'
import OrientationPicker from '@/components/OrientationPicker.vue'
import PullToRefresh from '@/components/PullToRefresh.vue'
const router = useRouter()
const devicesStore = useDevicesStore()
@@ -192,6 +196,19 @@ onMounted(() => {
devicesStore.fetchDevices()
})
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
const stackEl = ref<HTMLElement | null>(null)
function isAtTop(): boolean {
if (window.scrollY > 0) return false
return (stackEl.value?.scrollTop ?? 0) === 0
}
async function refreshDevices() {
await devicesStore.fetchDevices()
}
function onAddPhoto(deviceId: number) {
// File picker must be triggered in the user-gesture context (the click handler)
// before navigating, otherwise browsers block it as a popup.
+18
View File
@@ -1,5 +1,6 @@
<template>
<main class="library">
<PullToRefresh :is-at-top="isAtTop" :on-refresh="refreshLibrary">
<!-- Tabs -->
<div class="library__tabs" role="tablist">
<button
@@ -184,6 +185,7 @@
>Next </button>
</div>
</template>
</PullToRefresh>
<!-- Share sheet -->
<ShareSheet v-if="shareImageId !== null" v-model="shareSheetOpen" :image-id="shareImageId!" />
@@ -213,6 +215,7 @@ import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
import BaseButton from '@/components/BaseButton.vue'
import ApproveCard from '@/components/ApproveCard.vue'
import ShareSheet from '@/components/ShareSheet.vue'
import PullToRefresh from '@/components/PullToRefresh.vue'
import type { Device, Image, SharedImage } from '@/types'
const router = useRouter()
@@ -288,6 +291,21 @@ onMounted(() => {
if (activeTab.value === 'shared') loadShared(sharedTab.value)
})
// ── Pull-to-refresh ───────────────────────────────────────────────────────────
function isAtTop(): boolean {
return window.scrollY === 0
}
async function refreshLibrary() {
await Promise.all([
imagesStore.fetchImages(),
imagesStore.fetchPendingCount(),
devicesStore.fetchDevices(),
activeTab.value === 'shared' ? loadShared(sharedTab.value, sharedPage.value) : Promise.resolve(),
])
}
// For now "mine" and "all" show the same list; shared is a placeholder
const visibleImages = computed(() => imagesStore.images)