Revert "Merge branch 'swipe-drag-follow'"
This reverts commitd52fa90b6b, reversing changes made to77438d0532.
This commit is contained in:
@@ -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: '<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 })
|
||||
|
||||
// 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: '<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('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()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user