mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(client): move filter panels out of the scroll container
The Library, Series, and Authors filter panels were rendered inside the scrolling <main> element, so opening a panel after scrolling down placed it above the visible viewport and it appeared to do nothing until the page was scrolled back to the top. Render each panel as a sibling of <main> so it stays anchored regardless of scroll position. A height cap is intentionally avoided because the filter typeaheads use absolutely positioned dropdowns that an overflow container would clip. Add structural regression tests for the Series and Authors views that assert the panel renders outside the scroll container. Fixes #326
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import type { AuthorListSort, SortDirection } from '../types/author'
|
||||
import AuthorsView from './AuthorsView.vue'
|
||||
import AuthorFilters from '../components/AuthorFilters.vue'
|
||||
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn<(target: Element) => void>()
|
||||
unobserve = vi.fn<(target: Element) => void>()
|
||||
disconnect = vi.fn<() => void>()
|
||||
takeRecords = vi.fn<() => IntersectionObserverEntry[]>(() => [])
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
route: { params: {} as Record<string, string>, query: {} as Record<string, unknown>, fullPath: '/authors' },
|
||||
routerPush: vi.fn<(to: unknown) => Promise<void>>(),
|
||||
routerReplace: vi.fn<(to: unknown) => Promise<void>>(),
|
||||
fetchLibraries: vi.fn<() => Promise<void>>(),
|
||||
load: vi.fn<(reset?: boolean) => Promise<void>>(),
|
||||
q: null as unknown as { value: string },
|
||||
sort: null as unknown as { value: AuthorListSort },
|
||||
order: null as unknown as { value: SortDirection },
|
||||
libraryId: null as unknown as { value: number | null },
|
||||
hasPhoto: null as unknown as { value: boolean | null },
|
||||
minBookCount: null as unknown as { value: number | null },
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => mocks.route,
|
||||
useRouter: () => ({ push: mocks.routerPush, replace: mocks.routerReplace }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: { success: vi.fn<(message: string) => void>(), error: vi.fn<(message: string) => void>(), warning: vi.fn<(message: string) => void>() },
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useDisplaySettings', () => ({
|
||||
useDisplaySettings: () => ({
|
||||
gridGap: ref(16),
|
||||
viewMode: ref('grid'),
|
||||
authorCoverSize: ref(160),
|
||||
authorCoverShape: ref('circle'),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/library/composables/useLibraries', () => ({
|
||||
useLibraries: () => ({ libraries: ref([]), fetchLibraries: mocks.fetchLibraries }),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/auth/composables/usePermissions', () => ({
|
||||
usePermissions: () => ({ hasPermission: () => true, isDemoRestrictedAccount: ref(false), isSuperuser: ref(false) }),
|
||||
}))
|
||||
|
||||
vi.mock('../composables/useAuthorsList', () => ({
|
||||
useAuthorsList: () => ({
|
||||
items: ref([]),
|
||||
total: ref(0),
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
hasMore: ref(false),
|
||||
q: mocks.q,
|
||||
sort: mocks.sort,
|
||||
order: mocks.order,
|
||||
libraryId: mocks.libraryId,
|
||||
hasPhoto: mocks.hasPhoto,
|
||||
minBookCount: mocks.minBookCount,
|
||||
load: mocks.load,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../composables/useAuthorSelection', () => ({
|
||||
useAuthorSelection: () => ({
|
||||
selectionMode: ref(false),
|
||||
selectedIds: ref<number[]>([]),
|
||||
selectedCount: ref(0),
|
||||
enterSelectionMode: vi.fn<() => void>(),
|
||||
exitSelectionMode: vi.fn<() => void>(),
|
||||
toggleAuthor: vi.fn<(id: number) => void>(),
|
||||
rangeSelectTo: vi.fn<(id: number, ids: number[]) => void>(),
|
||||
selectAll: vi.fn<(ids: number[]) => void>(),
|
||||
isSelected: () => false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../composables/useRefreshingAuthors', () => ({
|
||||
useRefreshingAuthors: () => ({
|
||||
markRefreshing: vi.fn<(id: number) => void>(),
|
||||
clearRefreshing: vi.fn<(id: number) => void>(),
|
||||
isRefreshing: () => false,
|
||||
}),
|
||||
}))
|
||||
|
||||
async function mountView() {
|
||||
const wrapper = shallowMount(AuthorsView)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('AuthorsView filters panel placement', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
|
||||
mocks.route.query = {}
|
||||
mocks.routerPush.mockResolvedValue()
|
||||
mocks.routerReplace.mockResolvedValue()
|
||||
mocks.fetchLibraries.mockResolvedValue()
|
||||
mocks.load.mockResolvedValue()
|
||||
mocks.q = ref('')
|
||||
mocks.sort = ref('name')
|
||||
mocks.order = ref('asc')
|
||||
mocks.libraryId = ref(null)
|
||||
mocks.hasPhoto = ref(null)
|
||||
mocks.minBookCount = ref(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the desktop filters panel outside the scrolling <main> so it stays anchored when scrolled', async () => {
|
||||
mocks.route.query = { libraryId: '5' }
|
||||
const wrapper = await mountView()
|
||||
|
||||
const main = wrapper.find('main')
|
||||
expect(main.exists()).toBe(true)
|
||||
|
||||
const filters = wrapper.findComponent(AuthorFilters)
|
||||
expect(filters.exists()).toBe(true)
|
||||
// Regression guard for #326: the panel must NOT live inside the scroll container.
|
||||
expect(filters.element.closest('main')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render the desktop filters panel when no filter is active', async () => {
|
||||
mocks.route.query = {}
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.findComponent(AuthorFilters).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -614,9 +614,9 @@ watch(
|
||||
/>
|
||||
</section>
|
||||
|
||||
<main class="flex-1 min-h-0 overflow-y-auto pr-2">
|
||||
<!-- Desktop filters panel rendered outside <main> so it stays anchored when the list is scrolled -->
|
||||
<div v-if="filtersOpen && !mobileControlsExpanded" class="pr-2">
|
||||
<AuthorFilters
|
||||
v-if="filtersOpen && !mobileControlsExpanded"
|
||||
v-model:library-id="libraryId"
|
||||
v-model:has-photo="hasPhoto"
|
||||
v-model:min-book-count="minBookCount"
|
||||
@@ -626,7 +626,9 @@ watch(
|
||||
@clear="clearFilters"
|
||||
@close="closeFiltersPanel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main class="flex-1 min-h-0 overflow-y-auto pr-2">
|
||||
<div v-if="error" class="mb-3 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { flushPromises, shallowMount } from '@vue/test-utils'
|
||||
import type { CompletionStatus, SeriesListSort, SortDirection } from '../types/series'
|
||||
import SeriesView from './SeriesView.vue'
|
||||
import SeriesFilters from '../components/SeriesFilters.vue'
|
||||
|
||||
class MockIntersectionObserver {
|
||||
observe = vi.fn<(target: Element) => void>()
|
||||
unobserve = vi.fn<(target: Element) => void>()
|
||||
disconnect = vi.fn<() => void>()
|
||||
takeRecords = vi.fn<() => IntersectionObserverEntry[]>(() => [])
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
route: { params: {} as Record<string, string>, query: {} as Record<string, unknown>, fullPath: '/series' },
|
||||
routerPush: vi.fn<(to: unknown) => Promise<void>>(),
|
||||
routerReplace: vi.fn<(to: unknown) => Promise<void>>(),
|
||||
fetchLibraries: vi.fn<() => Promise<void>>(),
|
||||
load: vi.fn<(reset?: boolean) => Promise<void>>(),
|
||||
q: null as unknown as { value: string },
|
||||
sort: null as unknown as { value: SeriesListSort },
|
||||
order: null as unknown as { value: SortDirection },
|
||||
libraryId: null as unknown as { value: number | null },
|
||||
completionStatus: null as unknown as { value: CompletionStatus | null },
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRoute: () => mocks.route,
|
||||
useRouter: () => ({ push: mocks.routerPush, replace: mocks.routerReplace }),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useDisplaySettings', () => ({
|
||||
useDisplaySettings: () => ({ viewMode: ref('grid') }),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/library/composables/useLibraries', () => ({
|
||||
useLibraries: () => ({ libraries: ref([]), fetchLibraries: mocks.fetchLibraries }),
|
||||
}))
|
||||
|
||||
vi.mock('../composables/useSeriesList', () => ({
|
||||
useSeriesList: () => ({
|
||||
items: ref([]),
|
||||
total: ref(0),
|
||||
loading: ref(false),
|
||||
error: ref(null),
|
||||
hasMore: ref(false),
|
||||
q: mocks.q,
|
||||
sort: mocks.sort,
|
||||
order: mocks.order,
|
||||
libraryId: mocks.libraryId,
|
||||
completionStatus: mocks.completionStatus,
|
||||
load: mocks.load,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/services/storage', () => ({
|
||||
storage: {
|
||||
get: (_key: string, fallback: unknown) => fallback,
|
||||
set: vi.fn<(key: string, value: unknown) => void>(),
|
||||
remove: vi.fn<(key: string) => void>(),
|
||||
},
|
||||
}))
|
||||
|
||||
async function mountView() {
|
||||
const wrapper = shallowMount(SeriesView)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('SeriesView filters panel placement', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
|
||||
mocks.route.query = {}
|
||||
mocks.routerPush.mockResolvedValue()
|
||||
mocks.routerReplace.mockResolvedValue()
|
||||
mocks.fetchLibraries.mockResolvedValue()
|
||||
mocks.load.mockResolvedValue()
|
||||
mocks.q = ref('')
|
||||
mocks.sort = ref('name')
|
||||
mocks.order = ref('asc')
|
||||
mocks.libraryId = ref(null)
|
||||
mocks.completionStatus = ref(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders the desktop filters panel outside the scrolling <main> so it stays anchored when scrolled', async () => {
|
||||
// An active filter from the route opens the panel on mount (activeFilterCount > 0).
|
||||
mocks.route.query = { libraryId: '5' }
|
||||
const wrapper = await mountView()
|
||||
|
||||
const main = wrapper.find('main')
|
||||
expect(main.exists()).toBe(true)
|
||||
|
||||
const filters = wrapper.findComponent(SeriesFilters)
|
||||
expect(filters.exists()).toBe(true)
|
||||
// Regression guard for #326: the panel must NOT live inside the scroll container.
|
||||
expect(filters.element.closest('main')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not render the desktop filters panel when no filter is active', async () => {
|
||||
mocks.route.query = {}
|
||||
const wrapper = await mountView()
|
||||
|
||||
expect(wrapper.findComponent(SeriesFilters).exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -450,10 +450,9 @@ watch(seriesGridGap, (value) => {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<main class="flex-1 min-h-0 overflow-y-auto pr-2">
|
||||
<!-- Desktop filters panel -->
|
||||
<!-- Desktop filters panel rendered outside <main> so it stays anchored when the list is scrolled -->
|
||||
<div v-if="filtersOpen && !mobileControlsExpanded" class="pr-2">
|
||||
<SeriesFilters
|
||||
v-if="filtersOpen && !mobileControlsExpanded"
|
||||
:library-id="libraryId"
|
||||
:libraries="libraries"
|
||||
:completion-status="completionStatus"
|
||||
@@ -464,7 +463,9 @@ watch(seriesGridGap, (value) => {
|
||||
@clear="clearFilters"
|
||||
@close="closeFiltersPanel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<main class="flex-1 min-h-0 overflow-y-auto pr-2">
|
||||
<!-- Grid -->
|
||||
<div
|
||||
class="series-grid grid"
|
||||
|
||||
@@ -756,6 +756,67 @@ async function handleToggleCollapse() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Filter builder panel rendered outside <main> so it stays anchored when the list is scrolled -->
|
||||
<div v-if="filterOpen && !libraryNotFound" class="mb-4 p-3 rounded-md border border-border bg-card">
|
||||
<div class="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span class="text-xs font-medium text-muted-foreground sm:shrink-0">Filter rules</span>
|
||||
<div class="flex w-full flex-wrap items-center gap-1.5 sm:w-auto sm:flex-nowrap">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
v-if="activeFilterCount > 0"
|
||||
@click="saveAsSmartScopeOpen = true"
|
||||
class="flex min-h-7 items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Telescope :size="13" />
|
||||
<span class="hidden sm:inline whitespace-nowrap">Save as Smart Scope</span>
|
||||
<span class="sm:hidden whitespace-nowrap">Save Scope</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Save this filter as a named Smart Scope</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
v-if="activeFilterCount > 0"
|
||||
@click="saveFilter"
|
||||
class="flex min-h-7 items-center gap-1.5 rounded-md border px-3 py-1 text-xs font-medium transition-colors whitespace-nowrap"
|
||||
:class="
|
||||
isFilterSaved
|
||||
? 'border-primary/40 text-primary bg-primary/8'
|
||||
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
|
||||
"
|
||||
>
|
||||
<BookmarkCheck v-if="isFilterSaved" :size="13" />
|
||||
<Bookmark v-else :size="13" />
|
||||
{{ isFilterSaved ? 'Saved' : 'Save filter' }}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ isFilterSaved ? 'Filter saved' : 'Save filter for this library' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
v-if="hasSavedFilter"
|
||||
@click="forgetSavedFilter"
|
||||
class="h-6 w-6 flex items-center justify-center rounded text-muted-foreground/70 hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<X :size="11" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remove saved filter</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
class="min-h-7 whitespace-nowrap rounded-md border border-input px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
@click="closeFilterPanel"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<BookFilterBuilder v-model="filter" />
|
||||
</div>
|
||||
|
||||
<main :class="effectiveViewMode === 'table' ? 'flex-1 min-h-0 flex flex-col overflow-hidden' : 'flex-1 min-h-0 overflow-y-auto'">
|
||||
<EntityNotFound v-if="libraryNotFound" entity="Library" />
|
||||
|
||||
@@ -764,67 +825,6 @@ async function handleToggleCollapse() {
|
||||
|
||||
<ScanProgressBar :progress="scanProgress" class="mb-3" />
|
||||
|
||||
<!-- Filter builder panel -->
|
||||
<div v-if="filterOpen" class="mb-4 p-3 rounded-md border border-border bg-card">
|
||||
<div class="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<span class="text-xs font-medium text-muted-foreground sm:shrink-0">Filter rules</span>
|
||||
<div class="flex w-full flex-wrap items-center gap-1.5 sm:w-auto sm:flex-nowrap">
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
v-if="activeFilterCount > 0"
|
||||
@click="saveAsSmartScopeOpen = true"
|
||||
class="flex min-h-7 items-center gap-1.5 rounded-md border border-input bg-background px-3 py-1 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Telescope :size="13" />
|
||||
<span class="hidden sm:inline whitespace-nowrap">Save as Smart Scope</span>
|
||||
<span class="sm:hidden whitespace-nowrap">Save Scope</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Save this filter as a named Smart Scope</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
v-if="activeFilterCount > 0"
|
||||
@click="saveFilter"
|
||||
class="flex min-h-7 items-center gap-1.5 rounded-md border px-3 py-1 text-xs font-medium transition-colors whitespace-nowrap"
|
||||
:class="
|
||||
isFilterSaved
|
||||
? 'border-primary/40 text-primary bg-primary/8'
|
||||
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
|
||||
"
|
||||
>
|
||||
<BookmarkCheck v-if="isFilterSaved" :size="13" />
|
||||
<Bookmark v-else :size="13" />
|
||||
{{ isFilterSaved ? 'Saved' : 'Save filter' }}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ isFilterSaved ? 'Filter saved' : 'Save filter for this library' }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger as-child>
|
||||
<button
|
||||
v-if="hasSavedFilter"
|
||||
@click="forgetSavedFilter"
|
||||
class="h-6 w-6 flex items-center justify-center rounded text-muted-foreground/70 hover:text-destructive hover:bg-destructive/10 transition-colors"
|
||||
>
|
||||
<X :size="11" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Remove saved filter</TooltipContent>
|
||||
</Tooltip>
|
||||
<button
|
||||
class="min-h-7 whitespace-nowrap rounded-md border border-input px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
@click="closeFilterPanel"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<BookFilterBuilder v-model="filter" />
|
||||
</div>
|
||||
|
||||
<!-- Empty state: no matches with filters -->
|
||||
<div
|
||||
v-if="booksInitialized && !loading && books.length === 0 && activeFilterCount > 0"
|
||||
|
||||
Reference in New Issue
Block a user