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
@@ -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)
})
})