{{ feeds[currentIndex].feedTitle }}
{{ feeds[currentIndex].title }}
{{ feeds[currentIndex].timestamp }}
@@ -372,6 +448,17 @@ onMounted(async () => { .article-feature { width: 100%; max-width: 720px; + /* Governs the drag-release spring-back (dragOffsetX snapping to 0 after a + cancelled swipe). Overridden by .article-feature--dragging (below, so it + wins the tie) while actively tracking a touchmove, and by the + .slide-next/.slide-prev *-active rules (further below, so they win too) + during an actual page-turn — both need their own transform transition + timing instead. */ + transition: transform 0.2s ease; +} + +.article-feature--dragging { + transition: none; } /* Slide-in/out when paging between articles (swipe or nav buttons). The diff --git a/vue/src/components/__tests__/RssFeeds.spec.js b/vue/src/components/__tests__/RssFeeds.spec.js index e912511..7250ec9 100644 --- a/vue/src/components/__tests__/RssFeeds.spec.js +++ b/vue/src/components/__tests__/RssFeeds.spec.js @@ -360,178 +360,261 @@ describe('RssFeeds', () => { expect(titles).toEqual(['Article three']) }) - it('swipes left to navigate to the next article', async () => { - axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) - axios.put.mockResolvedValue({ status: 200 }) - - const wrapper = mount(RssFeeds) - await flushPromises() - - useFeeds().toggleViewMode() - await flushPromises() - - const article = wrapper.find('.article-single') - await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] }) - await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] }) - await flushPromises() - - expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two') - - wrapper.unmount() - }) - - it('swipes right to navigate to the previous article', async () => { - axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) - axios.put.mockResolvedValue({ status: 200 }) - - const wrapper = mount(RssFeeds) - await flushPromises() - - useFeeds().toggleViewMode() - await flushPromises() - await wrapper.findAll('.article-nav__btn')[1].trigger('click') - await flushPromises() - expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two') - - const article = wrapper.find('.article-single') - await article.trigger('touchstart', { touches: [{ clientX: 130, clientY: 100 }] }) - await article.trigger('touchend', { changedTouches: [{ clientX: 200, clientY: 100 }] }) - await flushPromises() - - expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one') - - wrapper.unmount() - }) - - it('does not navigate on a swipe shorter than the horizontal threshold', async () => { - axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) - axios.put.mockResolvedValue({ status: 200 }) - - const wrapper = mount(RssFeeds) - await flushPromises() - - useFeeds().toggleViewMode() - await flushPromises() - - const article = wrapper.find('.article-single') - await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] }) - await article.trigger('touchend', { changedTouches: [{ clientX: 180, clientY: 100 }] }) - await flushPromises() - - expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one') - - wrapper.unmount() - }) - - it('does not navigate on a mostly-vertical drag', async () => { - axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) - axios.put.mockResolvedValue({ status: 200 }) - - const wrapper = mount(RssFeeds) - await flushPromises() - - useFeeds().toggleViewMode() - await flushPromises() - - const article = wrapper.find('.article-single') - await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] }) - await article.trigger('touchend', { changedTouches: [{ clientX: 190, clientY: 250 }] }) - await flushPromises() - - expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one') - - wrapper.unmount() - }) - - it('does not navigate on a diagonal drag that exceeds the vertical restraint, even with a qualifying horizontal delta', async () => { - axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) - axios.put.mockResolvedValue({ status: 200 }) - - const wrapper = mount(RssFeeds) - await flushPromises() - - useFeeds().toggleViewMode() - await flushPromises() - - const article = wrapper.find('.article-single') - await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] }) - await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 200 }] }) - await flushPromises() - - expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one') - - wrapper.unmount() - }) - - it('ignores a multi-touch gesture (pinch-zoom)', async () => { - axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) - axios.put.mockResolvedValue({ status: 200 }) - - const wrapper = mount(RssFeeds) - await flushPromises() - - useFeeds().toggleViewMode() - await flushPromises() - - const article = wrapper.find('.article-single') - await article.trigger('touchstart', { - touches: [{ clientX: 200, clientY: 100 }, { clientX: 50, clientY: 100 }], + describe('swipe navigation in article view', () => { + // The drag-follow handler is rAF-throttled (dragOffsetX only updates once + // per frame) and reads the article's real offsetWidth for the + // commit-distance check — stub both so gestures are deterministic. Per + // the CLAUDE.md Vitest gotcha, avoid bare fake timers here since they'd + // also fake rAF and clobber this manual stub. (Faking Date, even scoped, + // was tried for the release-velocity check below and broke test-utils' + // event dispatch outright — real small setTimeout delays instead.) + beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 }) + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(400) }) - await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] }) - await flushPromises() - expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one') - - wrapper.unmount() - }) - - it('does not navigate when a swipe starts inside a code block', async () => { - axios.get.mockResolvedValueOnce({ - data: { - feeds: [ - { - title: 'My Feed', - items: [ - { id: 1, title: 'Article one', content: 'some code', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' }, - { id: 2, title: 'Article two', content: '
two
', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' }, - ], - }, - ], - }, + afterEach(() => { + // Not vi.unstubAllGlobals(): it would also remove the file-level + // IntersectionObserver stub (stubGlobal isn't describe-scoped), and + // every test in here re-stubs requestAnimationFrame in beforeEach + // anyway, so there's nothing to reset it back to for. + vi.restoreAllMocks() }) - axios.put.mockResolvedValue({ status: 200 }) - const wrapper = mount(RssFeeds) - await flushPromises() + // Drives one full touchstart -> touchmove -> touchend gesture. delayMs is + // a genuine wall-clock wait before releasing — it's how tests control the + // release velocity (deltaX / elapsed) the component computes, alongside + // the distance dragged. + async function swipe(wrapper, { startX, startY = 100, endX, endY = 100, delayMs = 0 }) { + const article = wrapper.find('.article-single') + await article.trigger('touchstart', { touches: [{ clientX: startX, clientY: startY }] }) + await article.trigger('touchmove', { touches: [{ clientX: endX, clientY: endY }] }) + if (delayMs > 0) { + await new Promise(resolve => setTimeout(resolve, delayMs)) + } + await article.trigger('touchend', { changedTouches: [{ clientX: endX, clientY: endY }] }) + await flushPromises() + } - useFeeds().toggleViewMode() - await flushPromises() + it('drags the article to follow the finger while swiping', async () => { + axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) + axios.put.mockResolvedValue({ status: 200 }) - await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] }) - await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] }) - await flushPromises() + const wrapper = mount(RssFeeds) + await flushPromises() + useFeeds().toggleViewMode() + await flushPromises() - expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one') + const article = wrapper.find('.article-single') + await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] }) + await article.trigger('touchmove', { touches: [{ clientX: 150, clientY: 100 }] }) - wrapper.unmount() - }) + const dragged = wrapper.find('.article-feature') + expect(dragged.classes()).toContain('article-feature--dragging') + expect(dragged.attributes('style')).toContain('translateX(-50px)') - it('does not throw when swiping with an empty feed list', async () => { - axios.get.mockResolvedValueOnce({ data: { feeds: [] } }) + wrapper.unmount() + }) - const wrapper = mount(RssFeeds) - await flushPromises() + it('completes the page-turn on a swipe past the distance threshold', async () => { + axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) + axios.put.mockResolvedValue({ status: 200 }) - useFeeds().toggleViewMode() - await flushPromises() + const wrapper = mount(RssFeeds) + await flushPromises() + useFeeds().toggleViewMode() + await flushPromises() - const article = wrapper.find('.article-single') - await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] }) - await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] }) - await flushPromises() + // 400px-wide article (mocked above), 35% commit ratio -> needs >140px. + await swipe(wrapper, { startX: 300, endX: 80 }) - expect(wrapper.text()).toContain('All caught up') + expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two') - wrapper.unmount() + wrapper.unmount() + }) + + it('completes the page-turn backwards on a swipe past the distance threshold', async () => { + axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) + axios.put.mockResolvedValue({ status: 200 }) + + const wrapper = mount(RssFeeds) + await flushPromises() + useFeeds().toggleViewMode() + await flushPromises() + await wrapper.findAll('.article-nav__btn')[1].trigger('click') + await flushPromises() + expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two') + + await swipe(wrapper, { startX: 80, endX: 300 }) + + expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one') + + wrapper.unmount() + }) + + it('springs back without navigating on a short, slow drag', async () => { + axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) + axios.put.mockResolvedValue({ status: 200 }) + + const wrapper = mount(RssFeeds) + await flushPromises() + useFeeds().toggleViewMode() + await flushPromises() + + // Only 20px of a 400px-wide article (well under the 140px distance + // threshold), released slowly enough that velocity doesn't qualify + // either (well under the 0.5px/ms flick threshold). + await swipe(wrapper, { startX: 200, endX: 180, delayMs: 150 }) + + expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one') + expect(wrapper.find('.article-feature').classes()).not.toContain('article-feature--dragging') + + wrapper.unmount() + }) + + it('completes the page-turn on a fast flick even under the distance threshold', async () => { + axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) + axios.put.mockResolvedValue({ status: 200 }) + + const wrapper = mount(RssFeeds) + await flushPromises() + useFeeds().toggleViewMode() + await flushPromises() + + // Only 60px (well under the 140px distance threshold), released with + // no extra delay. Date.now() only has ~1ms resolution, so a gesture + // this fast typically measures as 0ms elapsed — the component treats + // that as clearing the flick check outright (see the comment by + // isFastFlick in onArticleTouchEnd) rather than as "no velocity". + await swipe(wrapper, { startX: 200, endX: 140 }) + + expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two') + + wrapper.unmount() + }) + + it('does not drag or navigate on a mostly-vertical drag', async () => { + axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) + axios.put.mockResolvedValue({ status: 200 }) + + const wrapper = mount(RssFeeds) + await flushPromises() + useFeeds().toggleViewMode() + await flushPromises() + + await swipe(wrapper, { startX: 200, startY: 100, endX: 205, endY: 250 }) + + expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one') + + wrapper.unmount() + }) + + it('does not navigate when the vertical component dominates a diagonal drag', async () => { + axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) + axios.put.mockResolvedValue({ status: 200 }) + + const wrapper = mount(RssFeeds) + await flushPromises() + useFeeds().toggleViewMode() + await flushPromises() + + // More horizontal travel (70px) than in the previous test, but the + // vertical component (100px) is still larger — locks as a vertical + // scroll, not a swipe, regardless of the sizeable deltaX. + await swipe(wrapper, { startX: 200, startY: 100, endX: 130, endY: 200 }) + + expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one') + + wrapper.unmount() + }) + + it('applies resistance when dragging past the last article', async () => { + axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) + axios.put.mockResolvedValue({ status: 200 }) + + const wrapper = mount(RssFeeds) + await flushPromises() + useFeeds().toggleViewMode() + await flushPromises() + await wrapper.findAll('.article-nav__btn')[1].trigger('click') // now on the last article + await flushPromises() + + const article = wrapper.find('.article-single') + await article.trigger('touchstart', { touches: [{ clientX: 300, clientY: 100 }] }) + await article.trigger('touchmove', { touches: [{ clientX: 200, clientY: 100 }] }) // still swiping towards "next" + + // Damped to 35% of the raw -100px drag, not the full amount. + expect(wrapper.find('.article-feature').attributes('style')).toContain('translateX(-35px)') + + wrapper.unmount() + }) + + it('ignores a multi-touch gesture (pinch-zoom)', async () => { + axios.get.mockResolvedValueOnce({ data: { feeds: twoArticleFeed() } }) + axios.put.mockResolvedValue({ status: 200 }) + + const wrapper = mount(RssFeeds) + await flushPromises() + useFeeds().toggleViewMode() + await flushPromises() + + const article = wrapper.find('.article-single') + await article.trigger('touchstart', { + touches: [{ clientX: 200, clientY: 100 }, { clientX: 50, clientY: 100 }], + }) + await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] }) + await flushPromises() + + expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one') + + wrapper.unmount() + }) + + it('does not drag or navigate when a swipe starts inside a code block', async () => { + axios.get.mockResolvedValueOnce({ + data: { + feeds: [ + { + title: 'My Feed', + items: [ + { id: 1, title: 'Article one', content: 'some code', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' }, + { id: 2, title: 'Article two', content: '
two
', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' }, + ], + }, + ], + }, + }) + axios.put.mockResolvedValue({ status: 200 }) + + const wrapper = mount(RssFeeds) + await flushPromises() + useFeeds().toggleViewMode() + await flushPromises() + + await wrapper.find('pre').trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] }) + await wrapper.find('.article-single').trigger('touchmove', { touches: [{ clientX: 130, clientY: 100 }] }) + await wrapper.find('.article-single').trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] }) + await flushPromises() + + expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one') + expect(wrapper.find('.article-feature').classes()).not.toContain('article-feature--dragging') + + wrapper.unmount() + }) + + it('does not throw when swiping with an empty feed list', async () => { + axios.get.mockResolvedValueOnce({ data: { feeds: [] } }) + + const wrapper = mount(RssFeeds) + await flushPromises() + useFeeds().toggleViewMode() + await flushPromises() + + await swipe(wrapper, { startX: 200, endX: 80 }) + + expect(wrapper.text()).toContain('All caught up') + + wrapper.unmount() + }) }) })