Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfb926cec6 | ||
|
|
77438d0532 | ||
|
|
49ceec5125 | ||
|
|
15ac8c287b | ||
|
|
13d9c2c908 |
@@ -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;
|
||||||
|
|||||||
+198
-14
@@ -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 () => {
|
|||||||
>↓</button>
|
>↓</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,16 +261,23 @@ 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}`">
|
||||||
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
|
<article
|
||||||
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
|
class="article-feature"
|
||||||
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
|
:class="{ 'article-feature--dragging': isDragging }"
|
||||||
<p class="feed-original-link">
|
:style="dragOffsetX ? { transform: `translateX(${dragOffsetX}px)` } : null"
|
||||||
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article ↗</a>
|
:key="currentIndex"
|
||||||
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
|
>
|
||||||
</p>
|
<p class="article-feature__source">{{ feeds[currentIndex].feedTitle }}</p>
|
||||||
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
|
<h2 @click="loadReadable(feeds[currentIndex], currentIndex)" class="article-feature__title">{{ feeds[currentIndex].title }}</h2>
|
||||||
</article>
|
<h3 class="article-feature__meta">{{ feeds[currentIndex].timestamp }}</h3>
|
||||||
|
<p class="feed-original-link">
|
||||||
|
<a :href="feeds[currentIndex].url" target="_blank" rel="noopener noreferrer">Read original article ↗</a>
|
||||||
|
<button type="button" class="feed-share-btn" :title="shareLabel" @click="shareUrl(feeds[currentIndex].url)">{{ shareLabel }}</button>
|
||||||
|
</p>
|
||||||
|
<p class="article-feature__content" :class="{ 'article-feature__content--readable': feeds[currentIndex].readable }" v-html="feeds[currentIndex].content"></p>
|
||||||
|
</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"
|
||||||
>↑</button>
|
>↑</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"
|
||||||
>↓</button>
|
>↓</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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user