Change top bar to only appear whene scrolling up

This commit is contained in:
2026-08-28 07:42:59 +02:00
parent abb6002078
commit a2c947f870
2 changed files with 36 additions and 29 deletions
+12 -23
View File
@@ -29,11 +29,14 @@ function onFilterChange(event) {
} }
// Scroll-driven show/hide: the header slides out of view on scroll-down and // 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 // back in only on scroll-up — never merely by proximity to the top. This is
// resize behaviour) — the header is position:fixed, so translating it never // show/hide via `transform` (not the old resize behaviour) — the header is
// reflows content, and the app's programmatic scrolls resolve to sensible // position:fixed, so translating it never reflows content. The app's
// states: scrollTo(0, 0) lands near the top → shown; the list-view // programmatic scrolls resolve to sensible states without any top-of-page
// read-correction scrollBy moves only a few px → stays under the threshold. // 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 hidden = ref(false)
const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling const REVEAL_THRESHOLD = 12 // px of accumulated travel before toggling
// When the feed list mutates itself (read-correction scrollBy + array-splice // 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 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)
// Always reveal near the very top, and keep it visible while the menu is // Keep the header visible while the menu is open (the dropdown is anchored
// open (the dropdown is anchored to the header, so hiding it would slide the // to the header, so hiding it would slide the open menu off-screen).
// open menu off-screen). if (menuOpen.value) {
if (y <= headerHeight || menuOpen.value) {
hidden.value = false hidden.value = false
accumulated = 0 accumulated = 0
lastY = y lastY = y
@@ -99,11 +90,9 @@ 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 — and // a fixed size, so this is measured once on mount and never changes.
// 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 })
+24 -6
View File
@@ -330,10 +330,9 @@ describe('AppNav', () => {
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 })
// getBoundingClientRect() is all-zero in jsdom; give the header a real // getBoundingClientRect() is all-zero in jsdom; give the header a real
// height so onMounted()'s one-time measurement (which onScroll() then // height so onMounted()'s one-time measurement (used for the
// reuses instead of re-reading offsetHeight — see AppNav.vue) has // --app-nav-height CSS var — see AppNav.vue) has a realistic value.
// something to compare against for the "near the top" guard. This is a // This is a prototype-wide spy (scoped to this describe block only, restored in
// prototype-wide spy (scoped to this describe block only, restored in
// afterEach), so it returns a full DOMRect-shaped object rather than a // afterEach), so it returns a full DOMRect-shaped object rather than a
// partial one — no test here happens to call getBoundingClientRect() on // partial one — no test here happens to call getBoundingClientRect() on
// anything but the header, but a bare `{ height: 50 }` would silently // 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') 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() const wrapper = mountNav()
scrollTo(400) scrollTo(400)
await nextTick() await nextTick()
expect(wrapper.find('header').classes()).toContain('app-nav--hidden') 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) scrollTo(10)
await nextTick() await nextTick()
expect(wrapper.find('header').classes()).not.toContain('app-nav--hidden') 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 () => { it('does not let a programmatic upward jump reveal the header mid-read', async () => {
const { markProgrammaticScroll } = useFeeds() const { markProgrammaticScroll } = useFeeds()
const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000) const nowSpy = vi.spyOn(performance, 'now').mockReturnValue(1000)