Improved sticky header
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0.5rem;
|
||||
padding-top: var(--app-nav-height, 4.5rem);
|
||||
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { RouterLink, useRouter, useRoute } from 'vue-router'
|
||||
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
|
||||
import Modal from './modal/AddUrl.vue'
|
||||
|
||||
const router = useRouter()
|
||||
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)
|
||||
let titleObserver
|
||||
const headerRef = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
titleObserver = new IntersectionObserver(([entry]) => {
|
||||
navTitleVisible.value = entry.isIntersecting
|
||||
})
|
||||
titleObserver.observe(titleRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
titleObserver?.disconnect()
|
||||
const h = headerRef.value?.getBoundingClientRect().height ?? 0
|
||||
document.documentElement.style.setProperty('--app-nav-height', `${h}px`)
|
||||
})
|
||||
|
||||
const onFeedsPage = computed(() => route.path === '/feeds')
|
||||
@@ -69,9 +62,9 @@ function handleToggleLayout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="app-nav">
|
||||
<header ref="headerRef" class="app-nav">
|
||||
<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
|
||||
class="app-nav__hamburger"
|
||||
type="button"
|
||||
@@ -124,7 +117,12 @@ function handleToggleLayout() {
|
||||
|
||||
<style scoped>
|
||||
.app-nav {
|
||||
position: relative;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 20;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.app-nav__wrapper {
|
||||
|
||||
@@ -8,7 +8,6 @@ const {
|
||||
message,
|
||||
viewMode,
|
||||
currentIndex,
|
||||
leaveArticleView,
|
||||
layout,
|
||||
nextArticle,
|
||||
prevArticle,
|
||||
@@ -17,7 +16,6 @@ const {
|
||||
getReadable,
|
||||
setInitialLoad,
|
||||
showMessageForXSeconds,
|
||||
navTitleVisible,
|
||||
} = useFeeds()
|
||||
|
||||
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="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">
|
||||
<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"/>
|
||||
@@ -126,12 +121,6 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<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">← 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">
|
||||
<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"/>
|
||||
@@ -173,29 +162,6 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
position: fixed;
|
||||
right: 1rem;
|
||||
@@ -204,7 +170,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.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
|
||||
@@ -339,52 +305,6 @@ onMounted(async () => {
|
||||
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 {
|
||||
width: 100%;
|
||||
max-width: 720px;
|
||||
|
||||
@@ -11,7 +11,6 @@ const showModal = ref(false)
|
||||
const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu
|
||||
const currentIndex = ref(0)
|
||||
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 initialLoad = false
|
||||
@@ -185,7 +184,7 @@ function setupIntersectionObserver() {
|
||||
// The sticky topbar overlays the top of the viewport, so an article fully
|
||||
// hidden behind it should already count as "scrolled past" — shrink the
|
||||
// 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), {
|
||||
root: null, // Use the viewport as the root
|
||||
@@ -324,7 +323,6 @@ export function useFeeds() {
|
||||
leaveArticleView,
|
||||
layout,
|
||||
toggleLayout,
|
||||
navTitleVisible,
|
||||
nextArticle,
|
||||
prevArticle,
|
||||
fetchData,
|
||||
|
||||
Reference in New Issue
Block a user