From a2c947f8705cc1eba2265411fb9bd752ce9c378f Mon Sep 17 00:00:00 2001 From: mace Date: Fri, 28 Aug 2026 07:42:59 +0200 Subject: [PATCH] Change top bar to only appear whene scrolling up --- vue/src/components/AppNav.vue | 35 +++++++-------------- vue/src/components/__tests__/AppNav.spec.js | 30 ++++++++++++++---- 2 files changed, 36 insertions(+), 29 deletions(-) diff --git a/vue/src/components/AppNav.vue b/vue/src/components/AppNav.vue index b072c9a..fafb2fb 100644 --- a/vue/src/components/AppNav.vue +++ b/vue/src/components/AppNav.vue @@ -29,11 +29,14 @@ function onFilterChange(event) { } // Scroll-driven show/hide: the header slides out of view on scroll-down and -// back in on scroll-up. This is show/hide via `transform` (not the old -// resize behaviour) — the header is position:fixed, so translating it never -// reflows content, and the app's programmatic scrolls resolve to sensible -// states: scrollTo(0, 0) lands near the top → shown; the list-view -// read-correction scrollBy moves only a few px → stays under the threshold. +// back in only on scroll-up — never merely by proximity to the top. This is +// show/hide via `transform` (not the old resize behaviour) — the header is +// position:fixed, so translating it never reflows content. The app's +// programmatic scrolls resolve to sensible states without any top-of-page +// special case: scrollTo(0, 0) (article paging, layout toggle) is itself a +// large upward jump, so it crosses REVEAL_THRESHOLD like a real scroll-up and +// reveals the header that way; the list-view read-correction scrollBy moves +// only a few px → stays under the threshold and leaves a hidden header hidden. const hidden = ref(false) const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling // When the feed list mutates itself (read-correction scrollBy + array-splice @@ -45,25 +48,13 @@ const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling const PROGRAMMATIC_SUPPRESS_MS = 300 let lastY = 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() { const y = Math.max(0, window.scrollY) - // 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 menu off-screen). - if (y <= headerHeight || menuOpen.value) { + // Keep the header visible while the menu is open (the dropdown is anchored + // to the header, so hiding it would slide the open menu off-screen). + if (menuOpen.value) { hidden.value = false accumulated = 0 lastY = y @@ -99,11 +90,9 @@ function onScrollRaf() { onMounted(() => { // 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 - // a fixed size, so this is measured once on mount and never changes — and - // onScroll() reuses this same measurement instead of re-reading it. + // a fixed size, so this is measured once on mount and never changes. const h = headerRef.value?.getBoundingClientRect().height ?? 0 document.documentElement.style.setProperty('--app-nav-height', `${h}px`) - headerHeight = h lastY = Math.max(0, window.scrollY) window.addEventListener('scroll', onScrollRaf, { passive: true }) diff --git a/vue/src/components/__tests__/AppNav.spec.js b/vue/src/components/__tests__/AppNav.spec.js index 19e8056..8c7f7b7 100644 --- a/vue/src/components/__tests__/AppNav.spec.js +++ b/vue/src/components/__tests__/AppNav.spec.js @@ -330,10 +330,9 @@ describe('AppNav', () => { Object.defineProperty(window, 'scrollY', { value: 0, configurable: true, writable: true }) vi.stubGlobal('requestAnimationFrame', (cb) => { cb(); return 0 }) // getBoundingClientRect() is all-zero in jsdom; give the header a real - // height so onMounted()'s one-time measurement (which onScroll() then - // 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 + // height so onMounted()'s one-time measurement (used for the + // --app-nav-height CSS var — see AppNav.vue) has a realistic value. + // 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 @@ -373,19 +372,38 @@ describe('AppNav', () => { expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden') }) - it('always shows the header near the top of the page', async () => { + it('reveals the header on a large upward jump that lands near the top', 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. + // A jump this large (e.g. article paging's scrollTo(0, 0)) crosses + // REVEAL_THRESHOLD on its own — no top-of-page special case needed. scrollTo(10) await nextTick() expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden') }) + it('does not reveal the header merely for landing near the top', async () => { + const wrapper = mountNav() + // Hide it with a jump just past the header's own height (50px, mocked + // above) plus the reveal threshold. + scrollTo(62) + await nextTick() + expect(wrapper.find('header').classes()).toContain('app-nav--hidden') + + // Move back to right at the header's own height — within the "near the + // top" zone the old code always revealed for — but the upward delta + // (12px) doesn't exceed REVEAL_THRESHOLD, so a hidden header stays + // hidden: proximity to the top is no longer sufficient on its own. + scrollTo(50) + await nextTick() + + expect(wrapper.find('header').classes()).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)