Replace the feed select with a multi-feed filter modal

The header's feed <select> is now a funnel button that opens a modal with
an "All feeds" checkbox and one checkbox per feed with unread items. The
selection is either "All feeds" or at least one feed, is applied once when
the modal closes, and is persisted in localStorage.

- feedFilter holds string[] | null; setFeedFilter is a no-op when the
  selection is unchanged (order-insensitive), sparing callers the
  scroll/mark-read side effects.
- Selected feeds stay listed at 0 unread so they can be unchecked; titles
  whose feed no longer exists are pruned after each fetch.
- The persisted filter is cleared on logout and on 401 (handler extracted
  to httpAuthInterceptor.js so it is unit-testable).
- The modal locks body scroll while open so scrolling behind it can't
  mark articles read.
This commit is contained in:
2026-09-27 11:28:48 +02:00
parent 8f7ddb77c4
commit 5ed780baa3
10 changed files with 1217 additions and 114 deletions
+54 -31
View File
@@ -2,29 +2,27 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, useRouter, useRoute } from 'vue-router'
import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
import FeedFilter from './modal/FeedFilter.vue'
const router = useRouter()
const route = useRoute()
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds()
const headerRef = ref(null)
const filterBtnRef = ref(null)
const filterModalOpen = ref(false)
// Native <select>/<option> has no "is open" state — the closed box just shows
// the selected option's text verbatim, so per-option unread counts can only be
// toggled based on focus, since the popup is only ever shown while focused.
// @focus ~ opening (shows counts); @change (picking an option closes the
// popup but keeps focus, so no blur fires) and @blur (focus left without
// picking) ~ closed (counts hidden, so the closed box just shows the name).
const filterOpen = ref(false)
function onFilterFocus() {
filterOpen.value = true
function openFilterModal() {
refreshUnreadDisplay()
filterModalOpen.value = true
}
function onFilterChange(event) {
setFeedFilter(event.target.value || null)
filterOpen.value = false
// setFeedFilter() itself no-ops (order-insensitively) an unchanged selection,
// so there's nothing to check here before applying it.
async function handleFilterClose(selection) {
filterModalOpen.value = false
await setFeedFilter(selection)
filterBtnRef.value?.focus()
}
// Scroll-driven show/hide: the header slides out of view on scroll-down and
@@ -166,18 +164,31 @@ function handleToggleLayout() {
class="app-nav__sync-spinner"
aria-hidden="true"
></span>
<select
<button
v-if="onFeedsPage && feedTitles.length"
class="app-nav__filter"
:value="feedFilter ?? ''"
aria-label="Filter by feed"
@focus="onFilterFocus"
@change="onFilterChange"
@blur="filterOpen = false"
ref="filterBtnRef"
class="app-nav__filter-btn"
:class="{ 'app-nav__filter-btn--active': feedFilter !== null }"
type="button"
aria-haspopup="dialog"
:aria-expanded="filterModalOpen"
:aria-label="feedFilter !== null ? 'Filter feeds (active)' : 'Filter feeds'"
:title="feedFilter !== null ? 'Filter feeds (active)' : 'Filter feeds'"
@click="openFilterModal"
>
<option value="">All feeds</option>
<option v-for="title in feedTitles" :key="title" :value="title">{{ title }}<template v-if="filterOpen"> ({{ displayedFeedUnreadCounts[title] ?? 0 }})</template></option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"></polygon>
</svg>
</button>
<Teleport to="body">
<FeedFilter
:show="filterModalOpen"
:titles="feedTitles"
:counts="displayedFeedUnreadCounts"
:selected="feedFilter"
@close="handleFilterClose"
/>
</Teleport>
<button
class="app-nav__hamburger"
type="button"
@@ -283,25 +294,37 @@ function handleToggleLayout() {
}
}
.app-nav__filter {
flex-shrink: 1;
min-width: 0;
.app-nav__filter-btn {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
position: relative;
min-height: 44px;
max-width: clamp(90px, 40vw, 220px);
padding: 0.25rem 0.5rem;
min-width: 44px;
padding: 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 {
.app-nav__filter-btn:hover {
border-color: var(--color-border-hover);
}
.app-nav__filter-btn--active::after {
content: '';
position: absolute;
top: 6px;
right: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-accent);
}
.app-nav__hamburger {
display: inline-flex;
align-items: center;