remove variable header size

This commit is contained in:
2026-07-03 11:10:14 +02:00
parent b3cf5e4787
commit 3642635b20
4 changed files with 9 additions and 310 deletions
+6 -93
View File
@@ -1,96 +1,23 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router' import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds' import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
import Modal from './modal/AddUrl.vue' 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, feeds, setupIntersectionObserver, consumeScrollCorrection } = useFeeds() const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds()
const headerRef = ref(null) const headerRef = ref(null)
onMounted(() => { onMounted(() => {
// Measured once, on mount, from the header's full (expanded) size. Never // Drives #app's padding-top / RssFeeds' scroll-margin-top so content below
// re-measured when `compact` toggles: this only drives #app's initial // the fixed header isn't hidden behind it at scroll position 0. The header is
// padding-top / RssFeeds' scroll-margin-top, both of which only matter at // a fixed size, so this is measured once on mount and never changes.
// scroll position 0 — where the header is always expanded anyway. Updating
// it mid-scroll would shift page layout and jump the scroll position.
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`)
}) })
// Shrinks the header once scrolled past COMPACT_ENTER, restores it below
// COMPACT_EXIT. The gap between the two (rather than one toggle point)
// avoids flicker when the scroll position hovers near the boundary.
const COMPACT_ENTER = 64
const COMPACT_EXIT = 32
// List-view reading removes each article from the DOM once it's marked
// read, which collapses the remaining content back toward the top of the
// (now shorter) page — useFeeds.js corrects scrollY afterwards to keep the
// next article anchored under the header. That correction isn't a one-off
// flash: scrollY can sit low for as long as the user happens to pause
// between scroll gestures, so a short debounce alone doesn't cover it.
// consumeScrollCorrection() lets AppNav tell that "the page moved because
// content was removed" apart from a real user scroll and ignore it
// entirely. EXPAND_DEBOUNCE_MS stays as a second, smaller layer for plain
// scroll jitter (momentum bounce, trackpad micro-scrolls) unrelated to
// article removal.
const EXPAND_DEBOUNCE_MS = 150
const compact = ref(false)
let ticking = false
let expandTimer = null
function handleScroll() {
if (ticking) return
ticking = true
requestAnimationFrame(() => {
const y = window.scrollY
const wasCorrection = consumeScrollCorrection()
if (!compact.value && y > COMPACT_ENTER) {
compact.value = true
if (expandTimer) {
clearTimeout(expandTimer)
expandTimer = null
}
} else if (compact.value && y < COMPACT_EXIT && !wasCorrection) {
if (!expandTimer) {
expandTimer = setTimeout(() => {
expandTimer = null
if (window.scrollY < COMPACT_EXIT) {
compact.value = false
}
}, EXPAND_DEBOUNCE_MS)
}
} else if (expandTimer && !wasCorrection) {
clearTimeout(expandTimer)
expandTimer = null
}
ticking = false
})
}
onMounted(() => {
window.addEventListener('scroll', handleScroll, { passive: true })
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
if (expandTimer) {
clearTimeout(expandTimer)
expandTimer = null
}
})
// The read-tracking IntersectionObserver (useFeeds.js) bakes the header's
// current height into its rootMargin, so it needs re-syncing once the
// compact/expanded transition finishes. Deferred past the CSS transition
// duration (0.2s) so the header's rendered height has actually settled to
// its target value before it's measured.
watch(compact, () => {
setTimeout(setupIntersectionObserver, 220)
})
const onFeedsPage = computed(() => route.path === '/feeds') const onFeedsPage = computed(() => route.path === '/feeds')
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length) const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
@@ -138,7 +65,7 @@ function handleToggleLayout() {
</script> </script>
<template> <template>
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--compact': compact }"> <header ref="headerRef" class="app-nav">
<div class="app-nav__wrapper"> <div class="app-nav__wrapper">
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span> <span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
<button <button
@@ -208,21 +135,11 @@ function handleToggleLayout() {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.5rem; gap: 0.5rem;
padding: 0.75rem 1rem;
transition: padding 0.2s ease;
}
.app-nav--compact .app-nav__wrapper {
padding: 0.375rem 1rem; padding: 0.375rem 1rem;
} }
.app-nav__title { .app-nav__title {
font-weight: bold; font-weight: bold;
font-size: clamp(1.1rem, 4vw, 1.4rem);
transition: font-size 0.2s ease;
}
.app-nav--compact .app-nav__title {
font-size: clamp(0.95rem, 3.5vw, 1.1rem); font-size: clamp(0.95rem, 3.5vw, 1.1rem);
} }
@@ -315,10 +232,6 @@ function handleToggleLayout() {
@media (min-width: 768px) { @media (min-width: 768px) {
.app-nav__wrapper { .app-nav__wrapper {
padding: 1rem 2rem;
}
.app-nav--compact .app-nav__wrapper {
padding: 0.5rem 2rem; padding: 0.5rem 2rem;
} }
} }
+2 -166
View File
@@ -15,12 +15,6 @@ class FakeIntersectionObserver {
disconnect() {} disconnect() {}
} }
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
vi.stubGlobal('requestAnimationFrame', cb => cb())
function scrollTo(y) {
Object.defineProperty(window, 'scrollY', { value: y, configurable: true, writable: true })
window.dispatchEvent(new Event('scroll'))
}
describe('AppNav', () => { describe('AppNav', () => {
let router let router
@@ -29,7 +23,6 @@ describe('AppNav', () => {
localStorage.setItem('user-token', 'abc123') localStorage.setItem('user-token', 'abc123')
localStorage.setItem('user-id', '7') localStorage.setItem('user-id', '7')
vi.clearAllMocks() vi.clearAllMocks()
scrollTo(0)
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds() const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
feeds.value = [] feeds.value = []
@@ -51,17 +44,8 @@ describe('AppNav', () => {
await router.isReady() await router.isReady()
}) })
// Safety net: if a test using fake timers fails before reaching its own // Unmount every AppNav mounted via mountNav() after each test so mounted
// vi.useRealTimers(), real timers must still be restored so it doesn't // instances (and their router/menu listeners) don't pile up across the file.
// silently break the rAF-dependent scroll handling in later tests (fake
// timers by default also fake requestAnimationFrame).
//
// Also unmounts every AppNav mounted via mountNav() this test — without
// this, each test's `window.addEventListener('scroll', ...)` from AppNav's
// onMounted piles up across the whole file (nothing ever unmounts them
// otherwise), so a later scroll-correction test can have its
// consumeScrollCorrection() flag "stolen" by a stale listener from an
// earlier, unrelated test that happens to still be registered.
let mountedWrappers = [] let mountedWrappers = []
function mountNav(options = { global: { plugins: [router] } }) { function mountNav(options = { global: { plugins: [router] } }) {
const wrapper = mount(AppNav, options) const wrapper = mount(AppNav, options)
@@ -70,7 +54,6 @@ describe('AppNav', () => {
} }
afterEach(() => { afterEach(() => {
vi.useRealTimers()
for (const wrapper of mountedWrappers) { for (const wrapper of mountedWrappers) {
try { try {
wrapper.unmount() wrapper.unmount()
@@ -246,151 +229,4 @@ describe('AppNav', () => {
confirmSpy.mockRestore() confirmSpy.mockRestore()
}) })
it('compacts the header once scrolled past the enter threshold', async () => {
const wrapper = mountNav()
scrollTo(100)
await wrapper.vm.$nextTick()
expect(wrapper.find('.app-nav').classes()).toContain('app-nav--compact')
})
it('stays expanded for scroll offsets inside the hysteresis dead zone', async () => {
const wrapper = mountNav()
scrollTo(50)
await wrapper.vm.$nextTick()
expect(wrapper.find('.app-nav').classes()).not.toContain('app-nav--compact')
})
it('expands the header again once scrolled back near the top', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = mountNav()
scrollTo(100)
await wrapper.vm.$nextTick()
expect(wrapper.find('.app-nav').classes()).toContain('app-nav--compact')
scrollTo(10)
await wrapper.vm.$nextTick()
// The expand transition is debounced (see the article-read scroll-dip
// test below), so it shouldn't flip immediately...
expect(wrapper.find('.app-nav').classes()).toContain('app-nav--compact')
vi.advanceTimersByTime(150)
await wrapper.vm.$nextTick()
// ...but should once the debounce window elapses with scrollY still low.
expect(wrapper.find('.app-nav').classes()).not.toContain('app-nav--compact')
vi.useRealTimers()
})
it('does not flash expanded on a brief scroll dip below the exit threshold', async () => {
// Regression test: useFeeds.js's handleIntersection corrects the scroll
// position with a window.scrollBy after marking an article read, which can
// transiently dip scrollY below COMPACT_EXIT even while the user keeps
// scrolling down. That one-off dip must not visibly re-expand the header.
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const wrapper = mountNav()
scrollTo(100)
await wrapper.vm.$nextTick()
expect(wrapper.find('.app-nav').classes()).toContain('app-nav--compact')
scrollTo(10) // the corrective dip
await wrapper.vm.$nextTick()
scrollTo(80) // scrolling continues downward right after
await wrapper.vm.$nextTick()
vi.advanceTimersByTime(200)
await wrapper.vm.$nextTick()
expect(wrapper.find('.app-nav').classes()).toContain('app-nav--compact')
vi.useRealTimers()
})
it('stays compact through a real article-read scroll correction, even after a sustained pause', async () => {
// Regression test for the reported glitch: unlike a brief flash, marking
// an article read (list view) can leave scrollY genuinely low for as
// long as the user pauses between scroll gestures — e.g. right when they
// finish one article and are about to start the next — because the list
// view removes read articles from the DOM, collapsing the remainder back
// toward the top of the page. A fixed debounce alone can't distinguish
// that from a real "scrolled back to the top". This drives the actual
// useFeeds.js handleIntersection correction (not a simulated dip) and
// confirms the header stays compact even once the debounce window has
// fully elapsed with no further scrolling.
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const { feeds: feedsRef, handleIntersection, setInitialLoad } = useFeeds()
feedsRef.value = [{ id: 201, title: 'Only article' }]
setInitialLoad(true)
axios.put.mockResolvedValue({ status: 200 })
const wrapper = mountNav()
scrollTo(100)
await wrapper.vm.$nextTick()
expect(wrapper.find('.app-nav').classes()).toContain('app-nav--compact')
// jsdom's getBoundingClientRect().top defaults to 0 for any element, so a
// topbarHeight > 0 exercises the same "article now sits above the header"
// correction branch a real browser would take.
const observeDiv = document.createElement('div')
observeDiv.className = 'observe'
document.body.appendChild(observeDiv)
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
], 60)
await flushPromises()
// The real window.scrollBy correction would land scrollY low, like this.
scrollTo(10)
await wrapper.vm.$nextTick()
// Long past the debounce window, with no further scrolling — simulating
// the user pausing right at the top of the next article.
vi.advanceTimersByTime(300)
await wrapper.vm.$nextTick()
expect(wrapper.find('.app-nav').classes()).toContain('app-nav--compact')
document.body.removeChild(observeDiv)
setInitialLoad(false)
vi.useRealTimers()
})
it('stays compact while scrolling within the hysteresis dead zone', async () => {
const wrapper = mountNav()
scrollTo(100)
await wrapper.vm.$nextTick()
expect(wrapper.find('.app-nav').classes()).toContain('app-nav--compact')
scrollTo(50)
await wrapper.vm.$nextTick()
expect(wrapper.find('.app-nav').classes()).toContain('app-nav--compact')
})
it('does not throw on rapid repeated scroll events', async () => {
const wrapper = mountNav()
for (let y = 0; y <= 200; y += 5) {
scrollTo(y)
}
await wrapper.vm.$nextTick()
expect(wrapper.find('.app-nav').classes()).toContain('app-nav--compact')
})
it('removes the scroll listener on unmount', async () => {
const removeSpy = vi.spyOn(window, 'removeEventListener')
const wrapper = mountNav()
wrapper.unmount()
expect(removeSpy).toHaveBeenCalledWith('scroll', expect.any(Function))
removeSpy.mockRestore()
})
}) })
+1 -31
View File
@@ -13,7 +13,7 @@ class FakeIntersectionObserver {
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('useFeeds', () => { describe('useFeeds', () => {
const { feeds, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, consumeScrollCorrection } = useFeeds() const { feeds, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection } = useFeeds()
beforeEach(() => { beforeEach(() => {
localStorage.setItem('user-token', 'test-token') localStorage.setItem('user-token', 'test-token')
@@ -117,36 +117,6 @@ describe('useFeeds', () => {
setInitialLoad(false) setInitialLoad(false)
}) })
it('flags a scroll correction when marking an article read repositions the list', async () => {
// Regression coverage for the header-compacting glitch: AppNav's scroll
// handler needs a way to tell "the page moved because content was
// removed" apart from a real user scroll. jsdom's getBoundingClientRect()
// defaults every element's top to 0, so any topbarHeight > 0 exercises
// the same "first article now sits above the header" branch that fires
// in a real browser.
feeds.value = [{ id: 101, title: 'First' }, { id: 102, title: 'Second' }]
setInitialLoad(true)
axios.put.mockResolvedValue({ status: 200 })
const observeDiv = document.createElement('div')
observeDiv.className = 'observe'
document.body.appendChild(observeDiv)
expect(consumeScrollCorrection()).toBe(false)
await handleIntersection([
{ isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } },
], 60)
await flushPromises()
expect(consumeScrollCorrection()).toBe(true)
// Consume-once: asking again without another correction reports false.
expect(consumeScrollCorrection()).toBe(false)
document.body.removeChild(observeDiv)
setInitialLoad(false)
})
it('strips leftover embedded-video placeholder headings', async () => { it('strips leftover embedded-video placeholder headings', async () => {
feeds.value = [{ feeds.value = [{
id: 1, id: 1,
-20
View File
@@ -14,13 +14,6 @@ const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards'
let observer; // Declare observer outside the setup function let observer; // Declare observer outside the setup function
let initialLoad = false let initialLoad = false
// Set right before the scroll-position correction below, so AppNav's
// scroll-driven header compacting can tell "the page just moved because
// content was removed" apart from a real user scroll — otherwise every
// article marked read (which resets scrollY toward the top of the
// now-shorter list) would look identical to the user manually scrolling
// back to the top.
let scrollCorrectionPending = false
export function authHeaders() { export function authHeaders() {
return { return {
@@ -272,7 +265,6 @@ function handleIntersection(entries, topbarHeight = 0) {
if (first) { if (first) {
const top = first.getBoundingClientRect().top const top = first.getBoundingClientRect().top
if (top < topbarHeight) { if (top < topbarHeight) {
scrollCorrectionPending = true
window.scrollBy(0, top - topbarHeight) window.scrollBy(0, top - topbarHeight)
} }
} }
@@ -280,17 +272,6 @@ function handleIntersection(entries, topbarHeight = 0) {
}) })
} }
// Consume-once: returns whether a scroll-position correction is pending
// (and clears it), so a caller can tell this scroll event apart from a
// real user scroll without the flag lingering into later, unrelated ones.
function consumeScrollCorrection() {
if (scrollCorrectionPending) {
scrollCorrectionPending = false
return true
}
return false
}
function disconnectObserver() { function disconnectObserver() {
if (observer) { if (observer) {
observer.disconnect() observer.disconnect()
@@ -405,7 +386,6 @@ export function useFeeds() {
markAllRead, markAllRead,
showMessageForXSeconds, showMessageForXSeconds,
setupIntersectionObserver, setupIntersectionObserver,
consumeScrollCorrection,
disconnectObserver, disconnectObserver,
setInitialLoad, setInitialLoad,
handleIntersection, handleIntersection,