{{ feeds[currentIndex].feedTitle }}
{{ feeds[currentIndex].title }}
{{ feeds[currentIndex].timestamp }}
@@ -448,17 +372,6 @@ 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 7250ec9..e912511 100644 --- a/vue/src/components/__tests__/RssFeeds.spec.js +++ b/vue/src/components/__tests__/RssFeeds.spec.js @@ -360,261 +360,178 @@ describe('RssFeeds', () => { expect(titles).toEqual(['Article three']) }) - 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) + 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 }], }) + await article.trigger('touchend', { changedTouches: [{ clientX: 130, clientY: 100 }] }) + await flushPromises() - 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() + 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' }, + ], + }, + ], + }, }) + axios.put.mockResolvedValue({ status: 200 }) - // 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() - } + const wrapper = mount(RssFeeds) + await flushPromises() - it('drags the article to follow the finger while swiping', 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() + 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 article = wrapper.find('.article-single') - await article.trigger('touchstart', { touches: [{ clientX: 200, clientY: 100 }] }) - await article.trigger('touchmove', { touches: [{ clientX: 150, clientY: 100 }] }) + expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article one') - const dragged = wrapper.find('.article-feature') - expect(dragged.classes()).toContain('article-feature--dragging') - expect(dragged.attributes('style')).toContain('translateX(-50px)') + wrapper.unmount() + }) - wrapper.unmount() - }) + it('does not throw when swiping with an empty feed list', async () => { + axios.get.mockResolvedValueOnce({ data: { feeds: [] } }) - it('completes the page-turn 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() - const wrapper = mount(RssFeeds) - await flushPromises() - useFeeds().toggleViewMode() - await flushPromises() + useFeeds().toggleViewMode() + await flushPromises() - // 400px-wide article (mocked above), 35% commit ratio -> needs >140px. - await swipe(wrapper, { startX: 300, endX: 80 }) + 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') + expect(wrapper.text()).toContain('All caught up') - 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() - }) + wrapper.unmount() }) })