Author SHA1 Message Date
mathiasandClaude Sonnet 5 bfb926cec6 Replace threshold-based swipe with live drag-follow in article view
The article now visually tracks the finger in real time while swiping
(via a rAF-throttled touchmove handler), instead of only reacting
after the fact on touchend. On release it completes the page-turn if
dragged past ~35% of the article's width, or on a fast-enough flick
even short of that distance; otherwise it springs back to center.
Dragging past the first/last article is damped rather than free, for
tactile feedback that there's nothing further that way. A vertical
drag is detected within the first ~10px of movement and left alone so
it can't fight normal page scrolling.

Also fixes a real bug found while testing the flick path: Date.now()
has ~1ms resolution, so a genuinely fast flick often measures as
elapsed === 0. The velocity calculation was treating that as "zero
velocity" (dividing 0 elapsed into "no flick") instead of "as fast as
it gets" — now a zero-elapsed gesture with real movement counts as a
flick outright.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 22:03:41 +02:00
mathiasandClaude Sonnet 5 77438d0532 Merge branch 'fix-android-swipe-gesture'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:28:18 +02:00
mathiasandClaude Sonnet 5 49ceec5125 Fix article-view swipe being swallowed by Chrome-on-Android back gesture
On real Android touchscreens, a horizontal drag on content with nothing
left to scroll horizontally gets claimed by Chrome's native
swipe-to-go-back/forward gesture before our own touchend listener
(article-view swipe navigation) ever sees it — silently, with no
touchend firing at all. Synthetic touch testing (mouse-drag emulation
in devtools) doesn't go through that gesture recognizer, so it worked
there while doing nothing on an actual phone.

Fix: overscroll-behavior-x: contain on body stops Chrome from claiming
the gesture for its own navigation, letting our touchend fire normally.

Also drops the overflow-x: hidden added to .article-single for the
slide transition — body already has it (for the same full-bleed-image
reason), so it was redundant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:27:23 +02:00
mathiasandClaude Sonnet 5 15ac8c287b Merge branch 'article-view-swipe-nav'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-26 21:16:26 +02:00
mathiasandClaude Sonnet 5 13d9c2c908 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>
2026-08-26 21:14:52 +02:00
3 changed files with 476 additions and 14 deletions
+6
View File
@@ -81,6 +81,12 @@ body {
wider than the visible content area (scrollbar) and would otherwise wider than the visible content area (scrollbar) and would otherwise
introduce a horizontal scrollbar. */ introduce a horizontal scrollbar. */
overflow-x: hidden; overflow-x: hidden;
/* Without this, a horizontal touch drag on content with nothing left to
scroll (true here, given overflow-x: hidden above) is claimed by
Chrome-on-Android's native swipe-to-go-back/forward gesture before our
own touchend listener (article-view swipe navigation) ever sees it —
the gesture is silently swallowed with no error, no touchend fires. */
overscroll-behavior-x: contain;
color: var(--color-text); color: var(--color-text);
background: var(--color-background); background: var(--color-background);
transition: color 0.5s, background-color 0.5s; transition: color 0.5s, background-color 0.5s;
+189 -5
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue'; import { ref, onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
import { useFeeds } from '@/composables/useFeeds'; import { useFeeds } from '@/composables/useFeeds';
const { const {
@@ -34,6 +34,126 @@ function scrollToNextArticle() {
} }
} }
// Swipe-to-navigate in article view. The article visually tracks the finger
// while dragging (dragOffsetX/isDragging below), then either completes the
// page-turn or springs back to center on release. This is transient
// per-gesture state, not shared app state, so it's kept local to this
// component rather than in useFeeds() — nothing outside this component needs
// it.
const dragOffsetX = ref(0) // live translateX (px) applied to the current article while dragging or springing back
const isDragging = ref(false) // true only while actively tracking touchmove — disables the CSS transition so the article follows the finger with zero lag; the spring-back and the eventual page-turn both happen after this flips back to false, so they use the (slower, already-tuned) CSS transitions instead
let touchStartX = null
let touchStartY = null
let touchStartTime = null
let gestureAxis = null // 'horizontal' | 'vertical' | null (undecided) — locked in after DIRECTION_LOCK_THRESHOLD of movement, so a single gesture can't both page the article and scroll the page
let articleWidth = 0 // captured on touchstart; the live drag and the commit-distance check below are relative to it
let dragRafPending = false
let pendingDragOffset = 0
const DIRECTION_LOCK_THRESHOLD = 10 // px of movement before an ambiguous drag commits to horizontal (swipe) or vertical (scroll)
const COMMIT_DISTANCE_RATIO = 0.35 // fraction of the article's width dragged that counts as "far enough" to complete the page-turn
const COMMIT_VELOCITY = 0.5 // px/ms — a flick at least this fast completes the page-turn even short of the distance ratio
const BOUNDARY_DAMPING = 0.35 // drag multiplier applied when dragging past the first/last article, so it still moves a little (feedback) but resists
// Drives which slide-in/slide-out transition ('slide-next' / 'slide-prev') the
// article view uses — set right before the index actually changes, by both
// the nav buttons and the swipe handler below, so the two triggers animate
// identically.
const slideDirection = ref('next')
function navigateToNextArticle() {
slideDirection.value = 'next'
nextArticle()
}
function navigateToPrevArticle() {
slideDirection.value = 'prev'
prevArticle()
}
function onArticleTouchStart(event) {
// Ignore multi-touch (pinch-zoom) and swipes starting inside a
// horizontally-scrollable code block (.article-feature__content :deep(pre))
// — those should scroll the block, not page the article.
if (event.touches.length !== 1 || event.target.closest('pre')) {
touchStartX = null
return
}
const touch = event.touches[0]
touchStartX = touch.clientX
touchStartY = touch.clientY
touchStartTime = Date.now()
gestureAxis = null
const article = event.currentTarget.querySelector('.article-feature')
articleWidth = article ? article.offsetWidth : window.innerWidth
}
function onArticleTouchMove(event) {
if (touchStartX === null || event.touches.length !== 1) return
const touch = event.touches[0]
const deltaX = touch.clientX - touchStartX
const deltaY = touch.clientY - touchStartY
if (gestureAxis === null) {
if (Math.abs(deltaX) < DIRECTION_LOCK_THRESHOLD && Math.abs(deltaY) < DIRECTION_LOCK_THRESHOLD) return
gestureAxis = Math.abs(deltaX) > Math.abs(deltaY) ? 'horizontal' : 'vertical'
if (gestureAxis === 'horizontal') isDragging.value = true
}
if (gestureAxis !== 'horizontal') return // let the page scroll normally instead
// Stop the page from also scrolling vertically (and the browser's own
// horizontal-overscroll gesture) while we're driving the drag ourselves.
// Requires this listener to not be passive.
event.preventDefault()
const atBoundary = (deltaX < 0 && currentIndex.value === feeds.value.length - 1) ||
(deltaX > 0 && currentIndex.value === 0)
pendingDragOffset = atBoundary ? deltaX * BOUNDARY_DAMPING : deltaX
// rAF-throttled: touchmove can fire faster than the display refreshes. If a
// frame is already scheduled, later events in that window just update
// pendingDragOffset above — the callback always reads the latest value
// once it actually runs, rather than whatever triggered the first one.
if (dragRafPending) return
dragRafPending = true
requestAnimationFrame(() => {
dragOffsetX.value = pendingDragOffset
dragRafPending = false
})
}
function onArticleTouchEnd(event) {
if (touchStartX === null) return
const wasDraggingHorizontally = gestureAxis === 'horizontal'
// Computed fresh from the release position rather than read back from
// dragOffsetX, which is rAF-throttled and so can be a frame stale.
const deltaX = event.changedTouches[0].clientX - touchStartX
const elapsed = Date.now() - touchStartTime
touchStartX = null
touchStartY = null
gestureAxis = null
isDragging.value = false
if (!wasDraggingHorizontally) return
// Date.now() only has ~1ms resolution, so a genuinely fast flick often
// measures as elapsed === 0 — treat that as "as fast as it gets" (clears
// the flick check) rather than dividing by zero into "no velocity at all".
const isFastFlick = elapsed > 0 ? Math.abs(deltaX / elapsed) > COMMIT_VELOCITY : deltaX !== 0
const shouldComplete = Math.abs(deltaX) > articleWidth * COMMIT_DISTANCE_RATIO || isFastFlick
dragOffsetX.value = 0 // springs back to center — or, if completing, the (now off-screen-bound) article being replaced
if (!shouldComplete) return
if (deltaX < 0) {
navigateToNextArticle()
} else {
navigateToPrevArticle()
}
}
// Small images (icons, logos, ...) look bad stretched to the full-bleed // Small images (icons, logos, ...) look bad stretched to the full-bleed
// width used for readable article images — leave them at their natural size // width used for readable article images — leave them at their natural size
// instead. Intrinsic size is only known once the image has loaded, so check // instead. Intrinsic size is only known once the image has loaded, so check
@@ -126,7 +246,13 @@ onMounted(async () => {
>&darr;</button> >&darr;</button>
</div> </div>
<div v-else class="article-single"> <div
v-else
class="article-single"
@touchstart.passive="onArticleTouchStart"
@touchmove="onArticleTouchMove"
@touchend.passive="onArticleTouchEnd"
>
<div v-if="feeds.length == 0" class="empty-state"> <div v-if="feeds.length == 0" class="empty-state">
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
@@ -135,7 +261,13 @@ onMounted(async () => {
<p class="empty-state__label">All caught up</p> <p class="empty-state__label">All caught up</p>
</div> </div>
<template v-else> <template v-else>
<article class="article-feature"> <Transition :name="`slide-${slideDirection}`">
<article
class="article-feature"
:class="{ 'article-feature--dragging': isDragging }"
:style="dragOffsetX ? { transform: `translateX(${dragOffsetX}px)` } : null"
:key="currentIndex"
>
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p> <p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2> <h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3> <h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
@@ -145,6 +277,7 @@ onMounted(async () => {
</p> </p>
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p> <p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
</article> </article>
</Transition>
</template> </template>
<div class="article-nav"> <div class="article-nav">
@@ -153,14 +286,14 @@ onMounted(async () => {
class="article-nav__btn" class="article-nav__btn"
:disabled="currentIndex === 0" :disabled="currentIndex === 0"
aria-label="Previous article" aria-label="Previous article"
@click="prevArticle" @click="navigateToPrevArticle"
>&uarr;</button> >&uarr;</button>
<button <button
type="button" type="button"
class="article-nav__btn" class="article-nav__btn"
:disabled="feeds.length === 0 || currentIndex === feeds.length - 1" :disabled="feeds.length === 0 || currentIndex === feeds.length - 1"
aria-label="Next article" aria-label="Next article"
@click="nextArticle" @click="navigateToNextArticle"
>&darr;</button> >&darr;</button>
</div> </div>
</div> </div>
@@ -315,6 +448,57 @@ onMounted(async () => {
.article-feature { .article-feature {
width: 100%; width: 100%;
max-width: 720px; 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
outgoing article is pulled out of flex flow during its leave transition so
the incoming one can take its place immediately, instead of the two
stacking and doubling the page height for the transition's duration. */
.slide-next-enter-active,
.slide-next-leave-active,
.slide-prev-enter-active,
.slide-prev-leave-active {
transition: transform 0.28s ease, opacity 0.28s ease;
}
.slide-next-leave-active,
.slide-prev-leave-active {
position: absolute;
left: 0;
right: 0;
margin-left: auto;
margin-right: auto;
}
.slide-next-enter-from {
transform: translateX(100%);
opacity: 0;
}
.slide-next-leave-to {
transform: translateX(-100%);
opacity: 0;
}
.slide-prev-enter-from {
transform: translateX(-100%);
opacity: 0;
}
.slide-prev-leave-to {
transform: translateX(100%);
opacity: 0;
} }
.article-feature__source { .article-feature__source {
@@ -15,6 +15,20 @@ class FakeIntersectionObserver {
} }
vi.stubGlobal('IntersectionObserver', 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', () => { describe('RssFeeds', () => {
beforeEach(() => { beforeEach(() => {
localStorage.setItem('user-token', 'test-token') localStorage.setItem('user-token', 'test-token')
@@ -345,4 +359,262 @@ describe('RssFeeds', () => {
const titles = wrapper.findAll('.feed-title').map(el => el.text()) const titles = wrapper.findAll('.feed-title').map(el => el.text())
expect(titles).toEqual(['Article three']) 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)
})
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()
})
// 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()
}
it('drags the article to follow the finger while swiping', 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('touchmove', { touches: [{ clientX: 150, clientY: 100 }] })
const dragged = wrapper.find('.article-feature')
expect(dragged.classes()).toContain('article-feature--dragging')
expect(dragged.attributes('style')).toContain('translateX(-50px)')
wrapper.unmount()
})
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()
useFeeds().toggleViewMode()
await flushPromises()
// 400px-wide article (mocked above), 35% commit ratio -> needs >140px.
await swipe(wrapper, { startX: 300, endX: 80 })
expect(wrapper.find('.article-single .article-feature__title').text()).toBe('Article two')
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()
})
})
}) })