Feed filter implementation
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user