diff --git a/vue/src/components/AppNav.vue b/vue/src/components/AppNav.vue index ab1dec3..2348f6b 100644 --- a/vue/src/components/AppNav.vue +++ b/vue/src/components/AppNav.vue @@ -1,21 +1,86 @@ - + RSS Reader ({{ unreadCount }}) { expect(wrapper.find('.app-nav__unread').exists()).toBe(false) }) + describe('scroll-driven show/hide', () => { + // The scroll handler is rAF-throttled; run rAF synchronously so a single + // dispatched scroll event resolves before we assert. Per the CLAUDE.md + // Vitest gotcha, avoid bare fake timers here — they'd clobber this stub. + beforeEach(() => { + // Reset scroll position so each mount's lastY baseline starts at 0. + Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true }) + vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 }) + // offsetHeight is 0 in jsdom; give the header a real height so the + // "near the top" guard (scrollY <= headerH) has something to compare to. + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(50) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + function scrollTo(y) { + Object.defineProperty(window, 'scrollY', { value: y, configurable: true, writable: true }) + window.dispatchEvent(new Event('scroll')) + } + + it('hides the header when scrolling down past the threshold', async () => { + const wrapper = mountNav() + scrollTo(200) + await nextTick() + + expect(wrapper.find('header').classes()).toContain('app-nav--hidden') + }) + + it('reveals the header again when scrolling back up past the threshold', async () => { + const wrapper = mountNav() + scrollTo(200) + await nextTick() + expect(wrapper.find('header').classes()).toContain('app-nav--hidden') + + scrollTo(150) + await nextTick() + + expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden') + }) + + it('always shows the header near the top of the page', async () => { + const wrapper = mountNav() + scrollTo(400) + await nextTick() + expect(wrapper.find('header').classes()).toContain('app-nav--hidden') + + // Back within the header's own height of the top → always revealed. + scrollTo(10) + await nextTick() + + expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden') + }) + + it('does not let a programmatic upward jump reveal the header mid-read', async () => { + const { markProgrammaticScroll } = useFeeds() + const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000) + const wrapper = mountNav() + + // Hide it first via a normal scroll-down (no programmatic flag active). + scrollTo(400) + await nextTick() + expect(wrapper.find('header').classes()).toContain('app-nav--hidden') + + // A read-correction flags a programmatic scroll, then the page jumps + // upward. Within the window that upward jump must NOT reveal the header. + markProgrammaticScroll() // records lastProgrammaticScroll = 1000 + nowSpy.mockReturnValue(1100) // 100ms later — inside the 300ms window + scrollTo(200) + await nextTick() + expect(wrapper.find('header').classes()).toContain('app-nav--hidden') + + // Still allows hiding on scroll-down even while the flag is active. + scrollTo(500) + await nextTick() + expect(wrapper.find('header').classes()).toContain('app-nav--hidden') + + // Once the window elapses, a genuine scroll-up reveals it again. + nowSpy.mockReturnValue(1500) // 500ms after the flag — outside the window + scrollTo(450) + await nextTick() + expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden') + }) + + it('does not toggle on sub-threshold jitter', async () => { + const wrapper = mountNav() + // Start well below the top so the "near the top" guard doesn't apply. + scrollTo(300) + await nextTick() + // Reveal first so we're testing that small moves don't hide it. + scrollTo(260) + await nextTick() + expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden') + + scrollTo(268) // +8px, under the 12px threshold + await nextTick() + + expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden') + }) + }) + it('does not mark articles as read when the confirmation is dismissed', async () => { const { feeds } = useFeeds() feeds.value = [ diff --git a/vue/src/composables/useFeeds.js b/vue/src/composables/useFeeds.js index 5ecf050..256a06f 100644 --- a/vue/src/composables/useFeeds.js +++ b/vue/src/composables/useFeeds.js @@ -15,6 +15,16 @@ const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' let observer; // Declare observer outside the setup function let initialLoad = false +// Timestamp (performance.now()) of the most recent programmatic scroll / list +// mutation that moves the page without user intent — currently the list-view +// read-correction below. AppNav's auto-hide handler resyncs its scroll baseline +// (instead of treating the induced jump as a user scroll) for a short window +// after this, so removing read articles can't pop the header in/out mid-read. +const lastProgrammaticScroll = ref(0) +function markProgrammaticScroll() { + lastProgrammaticScroll.value = performance.now() +} + export function authHeaders() { return { headers: { @@ -247,6 +257,9 @@ function handleIntersection(entries, topbarHeight = 0) { observer = null } + // Both the array splice (via scroll anchoring) and the scrollBy correction + // below move the page — flag it so AppNav's header auto-hide ignores the jump. + markProgrammaticScroll() const readIds = new Set(readFeeds.map(feed => feed.id)) feeds.value = feeds.value.filter(feed => !readIds.has(feed.id)) @@ -265,6 +278,7 @@ function handleIntersection(entries, topbarHeight = 0) { if (first) { const top = first.getBoundingClientRect().top if (top < topbarHeight) { + markProgrammaticScroll() window.scrollBy(0, top - topbarHeight) } } @@ -389,5 +403,7 @@ export function useFeeds() { disconnectObserver, setInitialLoad, handleIntersection, + lastProgrammaticScroll, + markProgrammaticScroll, } } diff --git a/vue/src/router/index.js b/vue/src/router/index.js index 8e0aefd..47e9f7e 100644 --- a/vue/src/router/index.js +++ b/vue/src/router/index.js @@ -31,23 +31,13 @@ const router = createRouter({ ] }) -router.beforeEach((to, from, next) => { - if (to.meta.requiresAuth) { - let isAuthenticated = false; - if (localStorage.getItem("user-token") != null){ - isAuthenticated = true; - } - - if (!isAuthenticated) { - // Redirect to the login page - next('/login'); - } else { - // Proceed to the protected route - next(); - } - } else { - // For routes that don't require authentication, proceed without checking - next(); +router.beforeEach((to) => { + const isAuthenticated = localStorage.getItem("user-token") != null; + // Redirect unauthenticated users hitting a protected route to login; + // returning a value (instead of the deprecated next() callback) is the + // modern vue-router guard API. Returning nothing lets navigation proceed. + if (to.meta.requiresAuth && !isAuthenticated) { + return '/login'; } }); export default router