Merge branch 'article-view-swipe-nav'
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,61 @@ function scrollToNextArticle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Swipe-to-navigate in article view. Transient per-gesture state, not shared
|
||||||
|
// app state, so it's kept as plain component-local variables rather than in
|
||||||
|
// useFeeds() — nothing outside this component needs it.
|
||||||
|
let touchStartX = null
|
||||||
|
let touchStartY = null
|
||||||
|
|
||||||
|
const SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe
|
||||||
|
const SWIPE_RESTRAINT_Y = 75 // max vertical travel (px) before it's treated as a scroll, not a swipe
|
||||||
|
|
||||||
|
// 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
|
||||||
|
touchStartY = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
touchStartX = event.touches[0].clientX
|
||||||
|
touchStartY = event.touches[0].clientY
|
||||||
|
}
|
||||||
|
|
||||||
|
function onArticleTouchEnd(event) {
|
||||||
|
if (touchStartX === null) return
|
||||||
|
const touch = event.changedTouches[0]
|
||||||
|
const deltaX = touch.clientX - touchStartX
|
||||||
|
const deltaY = touch.clientY - touchStartY
|
||||||
|
touchStartX = null
|
||||||
|
touchStartY = null
|
||||||
|
|
||||||
|
if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) 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 +181,7 @@ onMounted(async () => {
|
|||||||
>↓</button>
|
>↓</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="article-single">
|
<div v-else class="article-single" @touchstart.passive="onArticleTouchStart" @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 +190,8 @@ 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" :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 +201,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 +210,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>
|
||||||
@@ -310,6 +367,7 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding-top: 1em;
|
padding-top: 1em;
|
||||||
padding-bottom: 5rem;
|
padding-bottom: 5rem;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-feature {
|
.article-feature {
|
||||||
@@ -317,6 +375,46 @@ onMounted(async () => {
|
|||||||
max-width: 720px;
|
max-width: 720px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 {
|
||||||
margin: 0 0 0.5em;
|
margin: 0 0 0.5em;
|
||||||
padding: 0 1rem;
|
padding: 0 1rem;
|
||||||
|
|||||||
@@ -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,179 @@ 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'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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