Add swipe navigation and slide transition to article view
Swiping left/right in the single-article view now pages to the next/previous article, same as the up/down nav buttons. Touch handlers live on .article-single: a swipe needs >=50px horizontal travel and <=75px vertical travel to count (so scrolling/diagonal drags don't trigger it), and multi-touch or swipes starting inside a horizontally scrollable <pre> block are ignored so code blocks keep their own scroll behavior. Also adds a direction-aware slide/fade transition (via Vue's <Transition>, keyed on currentIndex) when paging between articles, triggered by both the swipe gesture and the nav buttons. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,20 @@ class FakeIntersectionObserver {
|
||||
}
|
||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||
|
||||
// Shared by the swipe-navigation tests below, which all start from the same
|
||||
// two-article article view.
|
||||
function twoArticleFeed() {
|
||||
return [
|
||||
{
|
||||
title: 'My Feed',
|
||||
items: [
|
||||
{ id: 1, title: 'Article one', content: '<p>one</p>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
|
||||
{ id: 2, title: 'Article two', content: '<p>two</p>', url: 'https://example.test/2', timestamp: '2026-01-01 10:00:00' },
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
describe('RssFeeds', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('user-token', 'test-token')
|
||||
@@ -345,4 +359,179 @@ describe('RssFeeds', () => {
|
||||
const titles = wrapper.findAll('.feed-title').map(el => el.text())
|
||||
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 }],
|
||||
})
|
||||
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: '<pre>some code</pre>', url: 'https://example.test/1', timestamp: '2026-02-01 10:00:00' },
|
||||
{ id: 2, title: 'Article two', content: '<p>two</p>', 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('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 throw when swiping with an empty feed list', async () => {
|
||||
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||
|
||||
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.text()).toContain('All caught up')
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user