variable header size
This commit is contained in:
@@ -1,20 +1,96 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } 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 } = useFeeds()
|
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds, setupIntersectionObserver, consumeScrollCorrection } = useFeeds()
|
||||||
|
|
||||||
const headerRef = ref(null)
|
const headerRef = ref(null)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
// Measured once, on mount, from the header's full (expanded) size. Never
|
||||||
|
// re-measured when `compact` toggles: this only drives #app's initial
|
||||||
|
// padding-top / RssFeeds' scroll-margin-top, both of which only matter at
|
||||||
|
// 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)
|
||||||
@@ -62,7 +138,7 @@ function handleToggleLayout() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header ref="headerRef" class="app-nav">
|
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--compact': compact }">
|
||||||
<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
|
||||||
@@ -133,11 +209,21 @@ function handleToggleLayout() {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
|
transition: padding 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav--compact .app-nav__wrapper {
|
||||||
|
padding: 0.375rem 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__title {
|
.app-nav__title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: clamp(1.1rem, 4vw, 1.4rem);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-nav__unread {
|
.app-nav__unread {
|
||||||
@@ -231,5 +317,9 @@ function handleToggleLayout() {
|
|||||||
.app-nav__wrapper {
|
.app-nav__wrapper {
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-nav--compact .app-nav__wrapper {
|
||||||
|
padding: 0.5rem 2rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||||
import { mount, flushPromises } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
@@ -15,6 +15,12 @@ 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
|
||||||
@@ -23,6 +29,7 @@ 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 = []
|
||||||
@@ -44,15 +51,45 @@ describe('AppNav', () => {
|
|||||||
await router.isReady()
|
await router.isReady()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Safety net: if a test using fake timers fails before reaching its own
|
||||||
|
// vi.useRealTimers(), real timers must still be restored so it doesn't
|
||||||
|
// 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 = []
|
||||||
|
function mountNav(options = { global: { plugins: [router] } }) {
|
||||||
|
const wrapper = mount(AppNav, options)
|
||||||
|
mountedWrappers.push(wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
for (const wrapper of mountedWrappers) {
|
||||||
|
try {
|
||||||
|
wrapper.unmount()
|
||||||
|
} catch {
|
||||||
|
// already unmounted by the test itself — fine
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mountedWrappers = []
|
||||||
|
})
|
||||||
|
|
||||||
async function mountWithMenuOpen() {
|
async function mountWithMenuOpen() {
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await wrapper.find('.app-nav__hamburger').trigger('click')
|
await wrapper.find('.app-nav__hamburger').trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
return wrapper
|
return wrapper
|
||||||
}
|
}
|
||||||
|
|
||||||
it('toggles the menu open and closed via the hamburger button', async () => {
|
it('toggles the menu open and closed via the hamburger button', async () => {
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
|
||||||
|
|
||||||
@@ -164,7 +201,7 @@ describe('AppNav', () => {
|
|||||||
{ id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
{ id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||||
@@ -177,14 +214,14 @@ describe('AppNav', () => {
|
|||||||
{ id: 2, title: 'Article two', read: false, content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
{ id: 2, title: 'Article two', read: false, content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
|
expect(wrapper.find('.app-nav__title').text()).toContain('(1)')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides the unread count when there are no articles', async () => {
|
it('hides the unread count when there are no articles', async () => {
|
||||||
const wrapper = mount(AppNav, { global: { plugins: [router] } })
|
const wrapper = mountNav()
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|
||||||
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
|
||||||
@@ -208,4 +245,152 @@ 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,4 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { useFeeds } from '../useFeeds'
|
import { useFeeds } from '../useFeeds'
|
||||||
|
|
||||||
@@ -12,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 } = useFeeds()
|
const { feeds, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, consumeScrollCorrection } = useFeeds()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('user-token', 'test-token')
|
localStorage.setItem('user-token', 'test-token')
|
||||||
@@ -116,6 +117,36 @@ 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,
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ 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 {
|
||||||
@@ -265,6 +272,7 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -272,6 +280,17 @@ 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()
|
||||||
@@ -386,6 +405,7 @@ export function useFeeds() {
|
|||||||
markAllRead,
|
markAllRead,
|
||||||
showMessageForXSeconds,
|
showMessageForXSeconds,
|
||||||
setupIntersectionObserver,
|
setupIntersectionObserver,
|
||||||
|
consumeScrollCorrection,
|
||||||
disconnectObserver,
|
disconnectObserver,
|
||||||
setInitialLoad,
|
setInitialLoad,
|
||||||
handleIntersection,
|
handleIntersection,
|
||||||
|
|||||||
Reference in New Issue
Block a user