Improved sticky header
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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">← 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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user