diff --git a/frontend/src/components/PullToRefresh.vue b/frontend/src/components/PullToRefresh.vue new file mode 100644 index 0000000..0422b27 --- /dev/null +++ b/frontend/src/components/PullToRefresh.vue @@ -0,0 +1,186 @@ + + + + + diff --git a/frontend/src/test/components/PullToRefresh.test.ts b/frontend/src/test/components/PullToRefresh.test.ts new file mode 100644 index 0000000..2b6b5a5 --- /dev/null +++ b/frontend/src/test/components/PullToRefresh.test.ts @@ -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: '

hello

' }, + }) + 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(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((_, 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) + }) +}) diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 6d339dc..cad98f4 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -1,70 +1,73 @@