remove slide effect

This commit is contained in:
2026-08-27 21:33:58 +02:00
parent a26f31a150
commit f447a9a163
5 changed files with 101 additions and 84 deletions
+19 -5
View File
@@ -6,7 +6,7 @@ import Modal from './modal/AddUrl.vue'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, feedUnreadCounts, setFeedFilter, unreadCount, lastProgrammaticScroll } = useFeeds() const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds()
const headerRef = ref(null) const headerRef = ref(null)
@@ -27,15 +27,25 @@ const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
const PROGRAMMATIC_SUPPRESS_MS = 300 const PROGRAMMATIC_SUPPRESS_MS = 300
let lastY = 0 let lastY = 0
let accumulated = 0 let accumulated = 0
// Cached in onMounted() below instead of re-read via headerRef.offsetHeight
// on every scroll event. The header is a fixed size (see onMounted), so
// re-measuring it here was pure waste — worse, a DevTools performance trace
// showed that offsetHeight read landing right after an unrelated DOM write
// elsewhere on the page (e.g. the article view swapping in a new article)
// forces the browser into a synchronous, forced-reflow layout pass right
// there mid-script, instead of its normal scheduled one. Stays 0 until
// onMounted() below sets it (before the scroll listener is attached, so
// onScroll() never runs against the unset value) — keep that ordering if
// this ever changes.
let headerHeight = 0
function onScroll() { function onScroll() {
const y = Math.max(0, window.scrollY) const y = Math.max(0, window.scrollY)
const headerH = headerRef.value?.offsetHeight ?? 0
// Always reveal near the very top, and keep it visible while the menu is // Always reveal near the very top, and keep it visible while the menu is
// open (the dropdown is anchored to the header, so hiding it would slide the // open (the dropdown is anchored to the header, so hiding it would slide the
// open menu off-screen). // open menu off-screen).
if (y <= headerH || menuOpen.value) { if (y <= headerHeight || menuOpen.value) {
hidden.value = false hidden.value = false
accumulated = 0 accumulated = 0
lastY = y lastY = y
@@ -71,13 +81,16 @@ function onScrollRaf() {
onMounted(() => { onMounted(() => {
// Drives #app's padding-top / RssFeeds' scroll-margin-top so content below // Drives #app's padding-top / RssFeeds' scroll-margin-top so content below
// the fixed header isn't hidden behind it at scroll position 0. The header is // the fixed header isn't hidden behind it at scroll position 0. The header is
// a fixed size, so this is measured once on mount and never changes. // a fixed size, so this is measured once on mount and never changes — and
// onScroll() reuses this same measurement instead of re-reading it.
const h = headerRef.value?.getBoundingClientRect().height ?? 0 const h = headerRef.value?.getBoundingClientRect().height ?? 0
document.documentElement.style.setProperty('--app-nav-height', `${h}px`) document.documentElement.style.setProperty('--app-nav-height', `${h}px`)
headerHeight = h
lastY = Math.max(0, window.scrollY) lastY = Math.max(0, window.scrollY)
window.addEventListener('scroll', onScrollRaf, { passive: true }) window.addEventListener('scroll', onScrollRaf, { passive: true })
document.addEventListener('click', onDocumentClick) document.addEventListener('click', onDocumentClick)
refreshUnreadDisplay()
}) })
onUnmounted(() => { onUnmounted(() => {
@@ -152,10 +165,11 @@ function handleToggleLayout() {
class="app-nav__filter" class="app-nav__filter"
:value="feedFilter ?? ''" :value="feedFilter ?? ''"
aria-label="Filter by feed" aria-label="Filter by feed"
@focus="refreshUnreadDisplay"
@change="setFeedFilter($event.target.value || null)" @change="setFeedFilter($event.target.value || null)"
> >
<option value="">All feeds</option> <option value="">All feeds</option>
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }} ({{ feedUnreadCounts[title] ?? 0 }})</option> <option v-for="title in feedTitles" :key="title" :value="title">{{ title }} ({{ displayedFeedUnreadCounts[title] ?? 0 }})</option>
</select> </select>
<button <button
class="app-nav__hamburger" class="app-nav__hamburger"
+12 -66
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue'; import { onMounted, onBeforeUnmount, computed, nextTick, watch } from 'vue';
import { useFeeds } from '@/composables/useFeeds'; import { useFeeds } from '@/composables/useFeeds';
const { const {
@@ -43,22 +43,6 @@ let touchStartY = null
const SWIPE_THRESHOLD_X = 50 // min horizontal travel (px) to count as a swipe 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 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) { function onArticleTouchStart(event) {
// Ignore multi-touch (pinch-zoom) and swipes starting inside a // Ignore multi-touch (pinch-zoom) and swipes starting inside a
// horizontally-scrollable code block (.article-feature__content :deep(pre)) // horizontally-scrollable code block (.article-feature__content :deep(pre))
@@ -83,9 +67,9 @@ function onArticleTouchEnd(event) {
if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return if (Math.abs(deltaX) < SWIPE_THRESHOLD_X || Math.abs(deltaY) > SWIPE_RESTRAINT_Y) return
if (deltaX < 0) { if (deltaX < 0) {
navigateToNextArticle() nextArticle()
} else { } else {
navigateToPrevArticle() prevArticle()
} }
} }
@@ -189,9 +173,13 @@ onMounted(async () => {
</svg> </svg>
<p class="empty-state__label">All caught up</p> <p class="empty-state__label">All caught up</p>
</div> </div>
<template v-else> <!-- Deliberately no enter/leave transition here: Vue's <Transition>
<Transition :name="`slide-${slideDirection}`"> component forces a synchronous whole-document reflow
<article class="article-feature" :key="currentIndex"> (document.body.offsetHeight, in @vue/runtime-dom's forceReflow) on
every enter/leave, regardless of the app's own CSS — confirmed (via
a DevTools performance trace) to be what was corrupting the header's
native <select>/<button> rendering during article paging. -->
<article v-else 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>
@@ -201,8 +189,6 @@ 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>
<div class="article-nav"> <div class="article-nav">
<button <button
@@ -210,14 +196,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="navigateToPrevArticle" @click="prevArticle"
>&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="navigateToNextArticle" @click="nextArticle"
>&darr;</button> >&darr;</button>
</div> </div>
</div> </div>
@@ -381,46 +367,6 @@ 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;
+12 -3
View File
@@ -306,9 +306,18 @@ describe('AppNav', () => {
// Reset scroll position so each mount's lastY baseline starts at 0. // Reset scroll position so each mount's lastY baseline starts at 0.
Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true }) Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true })
vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 }) vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 })
// offsetHeight is 0 in jsdom; give the header a real height so the // getBoundingClientRect() is all-zero in jsdom; give the header a real
// "near the top" guard (scrollY <= headerH) has something to compare to. // height so onMounted()'s one-time measurement (which onScroll() then
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(50) // reuses instead of re-reading offsetHeight — see AppNav.vue) has
// something to compare against for the "near the top" guard. This is a
// prototype-wide spy (scoped to this describe block only, restored in
// afterEach), so it returns a full DOMRect-shaped object rather than a
// partial one — no test here happens to call getBoundingClientRect() on
// anything but the header, but a bare `{ height: 50 }` would silently
// hand back `undefined` for top/left/width/etc. to any code that did.
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
height: 50, width: 0, top: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {},
})
}) })
afterEach(() => { afterEach(() => {
+27 -1
View File
@@ -13,7 +13,7 @@ class FakeIntersectionObserver {
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('useFeeds', () => { describe('useFeeds', () => {
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds() const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds()
beforeEach(() => { beforeEach(() => {
localStorage.setItem('user-token', 'test-token') localStorage.setItem('user-token', 'test-token')
@@ -119,6 +119,32 @@ describe('useFeeds', () => {
setInitialLoad(false) setInitialLoad(false)
}) })
it('refreshes the filter dropdown\'s displayed per-feed counts when list-view scrolling marks an article read', async () => {
// displayedFeedUnreadCounts is a frozen snapshot of feedUnreadCounts (see
// useFeeds.js) — AppNav's filter <select> renders it instead of the live
// computed so paging through article view doesn't force it to repaint.
// List-view scrolling is a different code path (handleIntersection) and
// should keep it live regardless.
allItems.value = [
{ id: 301, feedTitle: 'Feed A', read: false },
{ id: 302, feedTitle: 'Feed A', read: false },
]
feeds.value = [...allItems.value]
refreshUnreadDisplay()
expect(displayedFeedUnreadCounts.value).toEqual({ 'Feed A': 2 })
setInitialLoad(true)
axios.put.mockResolvedValue({ status: 200 })
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
])
expect(displayedFeedUnreadCounts.value).toEqual({ 'Feed A': 1 })
setInitialLoad(false)
})
it('ignores the initial observer snapshot on connect so a scrolled-down reconnect cannot mass-mark', async () => { it('ignores the initial observer snapshot on connect so a scrolled-down reconnect cannot mass-mark', async () => {
feeds.value = [ feeds.value = [
{ id: 201, title: 'First' }, { id: 201, title: 'First' },
+22
View File
@@ -43,6 +43,21 @@ const unreadCount = computed(() => {
: allItems.value : allItems.value
return items.filter(i => !i.read).length return items.filter(i => !i.read).length
}) })
// AppNav renders this snapshot (refreshed via refreshUnreadDisplay, below)
// for the filter <select>'s per-feed option counts, instead of binding
// feedUnreadCounts directly — by design, this stays frozen while paging
// through article view (markCurrentArticleRead marks one article read per
// page turn, which would otherwise recompute it live on every single swipe).
// The header's own unread badge stays bound to the live unreadCount (it
// should update in real time regardless of view). Everywhere else that marks
// articles read — list-view scrolling (handleIntersection), opening/changing
// the filter, syncing, mark-all-read, leaving article view — refreshes this
// snapshot immediately, so it only ever looks stale during article-view
// paging itself.
const displayedFeedUnreadCounts = ref({})
function refreshUnreadDisplay() {
displayedFeedUnreadCounts.value = feedUnreadCounts.value
}
const message = ref('') const message = ref('')
const showModal = ref(false) const showModal = ref(false)
const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device
@@ -242,6 +257,7 @@ async function setFeedFilter(title) {
feedFilter.value = title // null for "All feeds" feedFilter.value = title // null for "All feeds"
currentIndex.value = 0 currentIndex.value = 0
applyFilter() applyFilter()
refreshUnreadDisplay()
await nextTick() await nextTick()
setupIntersectionObserver() setupIntersectionObserver()
} }
@@ -259,6 +275,7 @@ const fetchData = async () => {
items.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); items.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
allItems.value = items; allItems.value = items;
applyFilter(); applyFilter();
refreshUnreadDisplay();
await nextTick(); await nextTick();
setupIntersectionObserver(); setupIntersectionObserver();
} catch (error) { } catch (error) {
@@ -346,6 +363,7 @@ function handleIntersection(entries, topbarHeight = 0) {
feeds.value = feeds.value.filter(feed => !readIds.has(feed.id)) feeds.value = feeds.value.filter(feed => !readIds.has(feed.id))
// Mirror into the master so cleared filters don't resurrect read articles. // Mirror into the master so cleared filters don't resurrect read articles.
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id)) allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
refreshUnreadDisplay()
for (const feed of readFeeds) { for (const feed of readFeeds) {
markRead(feed.id) markRead(feed.id)
@@ -392,6 +410,7 @@ async function markAllRead() {
// is active) — drop exactly those from the master too. // is active) — drop exactly those from the master too.
allItems.value = allItems.value.filter(feed => !readIds.has(feed.id)) allItems.value = allItems.value.filter(feed => !readIds.has(feed.id))
currentIndex.value = 0 currentIndex.value = 0
refreshUnreadDisplay()
// markRead swallows its own errors, so Promise.all can't reject here. // markRead swallows its own errors, so Promise.all can't reject here.
await Promise.all(ids.map(id => markRead(id))) await Promise.all(ids.map(id => markRead(id)))
showMessageForXSeconds('All articles marked as read.', 5) showMessageForXSeconds('All articles marked as read.', 5)
@@ -418,6 +437,7 @@ async function leaveArticleView() {
currentIndex.value = 0 currentIndex.value = 0
viewMode.value = 'list' viewMode.value = 'list'
localStorage.setItem('viewMode', viewMode.value) localStorage.setItem('viewMode', viewMode.value)
refreshUnreadDisplay()
// The v-if on the list container tears down and recreates all .observe DOM // The v-if on the list container tears down and recreates all .observe DOM
// nodes when switching views, so the intersection observer must be // nodes when switching views, so the intersection observer must be
// re-pointed at the new elements after Vue has finished rendering. // re-pointed at the new elements after Vue has finished rendering.
@@ -479,6 +499,8 @@ export function useFeeds() {
feedTitles, feedTitles,
feedUnreadCounts, feedUnreadCounts,
unreadCount, unreadCount,
displayedFeedUnreadCounts,
refreshUnreadDisplay,
setFeedFilter, setFeedFilter,
showMessage, showMessage,
message, message,