Improved sticky header

This commit is contained in:
2026-06-16 12:04:42 +02:00
parent 570db2d948
commit e9c865a254
4 changed files with 16 additions and 99 deletions
+1
View File
@@ -4,6 +4,7 @@
max-width: 1280px; max-width: 1280px;
margin: 0 auto; margin: 0 auto;
padding: 0.5rem; padding: 0.5rem;
padding-top: var(--app-nav-height, 4.5rem);
font-weight: normal; font-weight: normal;
} }
+13 -15
View File
@@ -1,25 +1,18 @@
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } 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, navTitleVisible } = useFeeds() const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds } = useFeeds()
const titleRef = ref(null) const headerRef = ref(null)
let titleObserver
onMounted(() => { onMounted(() => {
titleObserver = new IntersectionObserver(([entry]) => { const h = headerRef.value?.getBoundingClientRect().height ?? 0
navTitleVisible.value = entry.isIntersecting document.documentElement.style.setProperty('--app-nav-height', `${h}px`)
})
titleObserver.observe(titleRef.value)
})
onUnmounted(() => {
titleObserver?.disconnect()
}) })
const onFeedsPage = computed(() => route.path === '/feeds') const onFeedsPage = computed(() => route.path === '/feeds')
@@ -69,9 +62,9 @@ function handleToggleLayout() {
</script> </script>
<template> <template>
<header class="app-nav"> <header ref="headerRef" class="app-nav">
<div class="app-nav__wrapper"> <div class="app-nav__wrapper">
<span ref="titleRef" 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
class="app-nav__hamburger" class="app-nav__hamburger"
type="button" type="button"
@@ -124,7 +117,12 @@ function handleToggleLayout() {
<style scoped> <style scoped>
.app-nav { .app-nav {
position: relative; position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 20;
background: var(--color-background);
} }
.app-nav__wrapper { .app-nav__wrapper {
+1 -81
View File
@@ -8,7 +8,6 @@ const {
message, message,
viewMode, viewMode,
currentIndex, currentIndex,
leaveArticleView,
layout, layout,
nextArticle, nextArticle,
prevArticle, prevArticle,
@@ -17,7 +16,6 @@ const {
getReadable, getReadable,
setInitialLoad, setInitialLoad,
showMessageForXSeconds, showMessageForXSeconds,
navTitleVisible,
} = useFeeds() } = useFeeds()
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length) const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
@@ -92,9 +90,6 @@ onMounted(async () => {
<div v-if="showMessage" class="message">{{ message }}</div> <div v-if="showMessage" class="message">{{ message }}</div>
<div v-if="viewMode === 'list'" id='article' class='article' :class="{ 'article--cards': layout === 'cards' }"> <div v-if="viewMode === 'list'" id='article' class='article' :class="{ 'article--cards': layout === 'cards' }">
<div v-if="feeds.length" class="list-topbar">
<span v-if="!navTitleVisible" class="list-topbar__title">RSS Reader<span v-if="unreadCount" class="list-topbar__unread"> ({{ unreadCount }})</span></span>
</div>
<div v-if="feeds.length == 0" class="empty-state"> <div v-if="feeds.length == 0" class="empty-state">
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
@@ -126,12 +121,6 @@ onMounted(async () => {
</div> </div>
<div v-else class="article-single"> <div v-else class="article-single">
<div class="article-single__topbar">
<div class="article-single__topbar-inner">
<button type="button" class="article-single__back" @click="leaveArticleView">&larr; Back to list</button>
<span v-if="feeds.length" class="article-single__progress">{{ currentIndex + 1 }} / {{ feeds.length }}</span>
</div>
</div>
<div v-if="feeds.length == 0" class="empty-state"> <div v-if="feeds.length == 0" class="empty-state">
<svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> <svg class="empty-state__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/> <circle cx="12" cy="12" r="10"/>
@@ -173,29 +162,6 @@ onMounted(async () => {
</template> </template>
<style scoped> <style scoped>
.list-topbar {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0;
margin-bottom: 0.5rem;
background: var(--color-background);
}
.list-topbar__title {
font-weight: bold;
font-size: clamp(1.1rem, 4vw, 1.4rem);
}
.list-topbar__unread {
font-weight: normal;
opacity: 0.6;
}
.list-skip-btn { .list-skip-btn {
position: fixed; position: fixed;
right: 1rem; right: 1rem;
@@ -204,7 +170,7 @@ onMounted(async () => {
} }
.observe { .observe {
scroll-margin-top: 3.5rem; scroll-margin-top: var(--app-nav-height, 4.5rem);
} }
/* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and /* Plain vertical stack of bordered "cards" — deliberately not flex/grid, and
@@ -339,52 +305,6 @@ onMounted(async () => {
padding-bottom: 5rem; padding-bottom: 5rem;
} }
.article-single__topbar {
position: sticky;
top: 0;
z-index: 10;
align-self: flex-start;
width: 100vw;
margin-left: 50%;
margin-bottom: 1rem;
transform: translateX(-50%);
background: var(--color-background);
}
.article-single__topbar-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
max-width: 720px;
margin: 0 auto;
padding: 0.5rem 1rem;
}
.article-single__back {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.5rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
cursor: pointer;
}
.article-single__back:hover {
border-color: var(--color-border-hover);
}
.article-single__progress {
font-size: 0.85rem;
color: var(--color-text);
opacity: 0.6;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.article-feature { .article-feature {
width: 100%; width: 100%;
max-width: 720px; max-width: 720px;
+1 -3
View File
@@ -11,7 +11,6 @@ const showModal = ref(false)
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
const currentIndex = ref(0) const currentIndex = ref(0)
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu
const navTitleVisible = ref(true) // whether AppNav's "RSS Reader (N)" title is currently in view
let observer; // Declare observer outside the setup function let observer; // Declare observer outside the setup function
let initialLoad = false let initialLoad = false
@@ -185,7 +184,7 @@ function setupIntersectionObserver() {
// The sticky topbar overlays the top of the viewport, so an article fully // The sticky topbar overlays the top of the viewport, so an article fully
// hidden behind it should already count as "scrolled past" — shrink the // hidden behind it should already count as "scrolled past" — shrink the
// observer's root by that height so it stops intersecting at that point. // observer's root by that height so it stops intersecting at that point.
const topbarHeight = document.querySelector('.list-topbar')?.getBoundingClientRect().height ?? 0; const topbarHeight = document.querySelector('.app-nav')?.getBoundingClientRect().height ?? 0;
observer = new IntersectionObserver((entries) => handleIntersection(entries, topbarHeight), { observer = new IntersectionObserver((entries) => handleIntersection(entries, topbarHeight), {
root: null, // Use the viewport as the root root: null, // Use the viewport as the root
@@ -324,7 +323,6 @@ export function useFeeds() {
leaveArticleView, leaveArticleView,
layout, layout,
toggleLayout, toggleLayout,
navTitleVisible,
nextArticle, nextArticle,
prevArticle, prevArticle,
fetchData, fetchData,