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,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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user