Feed filter implementation

This commit is contained in:
2026-07-12 12:28:32 +02:00
parent 0e3142bac9
commit 31991ea1f8
4 changed files with 306 additions and 10 deletions
+44 -2
View File
@@ -6,7 +6,7 @@ import Modal from './modal/AddUrl.vue'
const router = useRouter()
const route = useRoute()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feeds, lastProgrammaticScroll } = useFeeds()
const { sync, showModal, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, lastProgrammaticScroll } = useFeeds()
const headerRef = ref(null)
@@ -77,15 +77,16 @@ onMounted(() => {
lastY = Math.max(0, window.scrollY)
window.addEventListener('scroll', onScrollRaf, { passive: true })
document.addEventListener('click', onDocumentClick)
})
onUnmounted(() => {
window.removeEventListener('scroll', onScrollRaf)
document.removeEventListener('click', onDocumentClick)
})
const onFeedsPage = computed(() => route.path === '/feeds')
const unreadCount = computed(() => feeds.value.filter(f => !f.read).length)
const menuOpen = ref(false)
@@ -97,6 +98,19 @@ function closeMenu() {
menuOpen.value = false
}
// The open menu is a thin absolutely-positioned strip under the header, so its
// `@click.self` only catches clicks on that strip — a click anywhere else on
// the page never reaches it. This document-level listener closes the menu on
// any outside click. The hamburger is excluded (it has its own toggle, so an
// opening click mustn't immediately re-close), and clicks inside the menu are
// left to the menu items' own handlers.
function onDocumentClick(event) {
if (!menuOpen.value) return
const target = event.target
if (target.closest?.('.app-nav__hamburger') || target.closest?.('.app-nav__menu')) return
closeMenu()
}
async function logout() {
await logoutSession()
closeMenu()
@@ -133,6 +147,16 @@ function handleToggleLayout() {
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
<div class="app-nav__wrapper">
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
<select
v-if="onFeedsPage && feedTitles.length"
class="app-nav__filter"
:value="feedFilter ?? ''"
aria-label="Filter by feed"
@change="setFeedFilter($event.target.value || null)"
>
<option value="">All feeds</option>
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }}</option>
</select>
<button
class="app-nav__hamburger"
type="button"
@@ -210,6 +234,7 @@ function handleToggleLayout() {
}
.app-nav__title {
margin-right: auto;
font-weight: bold;
font-size: clamp(0.95rem, 3.5vw, 1.1rem);
}
@@ -219,6 +244,23 @@ function handleToggleLayout() {
opacity: 0.6;
}
.app-nav__filter {
min-height: 44px;
max-width: clamp(120px, 40vw, 220px);
padding: 0.25rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: transparent;
color: var(--color-text);
font: inherit;
cursor: pointer;
text-overflow: ellipsis;
}
.app-nav__filter:hover {
border-color: var(--color-border-hover);
}
.app-nav__hamburger {
display: inline-flex;
align-items: center;
+91 -5
View File
@@ -25,8 +25,13 @@ describe('AppNav', () => {
localStorage.setItem('user-id', '7')
vi.clearAllMocks()
const { feeds, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
const { feeds, allItems, feedFilter, lastProgrammaticScroll, showMessage, message, showModal, viewMode, currentIndex, layout } = useFeeds()
feeds.value = []
allItems.value = []
feedFilter.value = null
// Module-singleton state: a prior test may have stamped this via a mocked
// performance.now(); reset it so the scroll-driven reveal gate starts clean.
lastProgrammaticScroll.value = 0
showMessage.value = false
message.value = ''
showModal.value = false
@@ -84,6 +89,17 @@ describe('AppNav', () => {
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
})
it('closes the menu on a click outside of it', async () => {
const wrapper = await mountWithMenuOpen()
expect(wrapper.find('.app-nav__menu').exists()).toBe(true)
// A click anywhere outside the menu strip (e.g. on page content) closes it.
document.body.dispatchEvent(new Event('click', { bubbles: true }))
await nextTick()
expect(wrapper.find('.app-nav__menu').exists()).toBe(false)
})
it('clears stored credentials and redirects to login on logout', async () => {
const wrapper = await mountWithMenuOpen()
@@ -179,8 +195,10 @@ describe('AppNav', () => {
})
it('shows the unread count in the title when there are articles', async () => {
const { feeds } = useFeeds()
feeds.value = [
// The badge is the global unread total, sourced from the master list
// (allItems) so it stays correct regardless of any active feed filter.
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, title: 'Article one', content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, title: 'Article two', content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
]
@@ -192,8 +210,8 @@ describe('AppNav', () => {
})
it('excludes already-read articles from the counter while in article view', async () => {
const { feeds } = useFeeds()
feeds.value = [
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, title: 'Article one', read: true, content: '', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, title: 'Article two', read: false, content: '', url: 'https://example.test/2', timestamp: '2026-01-02' },
]
@@ -211,6 +229,74 @@ describe('AppNav', () => {
expect(wrapper.find('.app-nav__unread').exists()).toBe(false)
})
it('renders a feed filter with an "All feeds" option plus one per distinct feed', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed B', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-02' },
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-01' },
{ id: 3, feedTitle: 'Feed A', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
]
const wrapper = mountNav()
await flushPromises()
const options = wrapper.find('.app-nav__filter').findAll('option').map(o => o.text())
// "All feeds" first, then distinct titles sorted alphabetically.
expect(options).toEqual(['All feeds', 'Feed A', 'Feed B'])
})
it('does not render the feed filter when there are no feeds', async () => {
const wrapper = mountNav()
await flushPromises()
expect(wrapper.find('.app-nav__filter').exists()).toBe(false)
})
it('applies the selected feed to the displayed list, and "All feeds" restores it', async () => {
const { allItems, feeds, feedFilter } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, feedTitle: 'Feed B', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
]
const wrapper = mountNav()
await flushPromises()
const select = wrapper.find('.app-nav__filter')
await select.setValue('Feed A')
await flushPromises()
expect(feedFilter.value).toBe('Feed A')
expect(feeds.value.map(f => f.id)).toEqual([1])
await select.setValue('')
await flushPromises()
expect(feedFilter.value).toBeNull()
expect(feeds.value.map(f => f.id)).toEqual([1, 2])
})
it('shows the selected feed\'s unread count in the title when a filter is active', async () => {
const { allItems } = useFeeds()
allItems.value = [
{ id: 1, feedTitle: 'Feed A', title: 'a', url: 'https://example.test/1', timestamp: '2026-01-01' },
{ id: 2, feedTitle: 'Feed A', title: 'b', url: 'https://example.test/2', timestamp: '2026-01-02' },
{ id: 3, feedTitle: 'Feed B', title: 'c', url: 'https://example.test/3', timestamp: '2026-01-03' },
]
const wrapper = mountNav()
await flushPromises()
// No filter → global total (3).
expect(wrapper.find('.app-nav__title').text()).toContain('(3)')
await wrapper.find('.app-nav__filter').setValue('Feed A')
await flushPromises()
// Filtered → unread in Feed A only (2).
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
})
describe('scroll-driven show/hide', () => {
// The scroll handler is rAF-throttled; run rAF synchronously so a single
// dispatched scroll event resolves before we assert. Per the CLAUDE.md