From 007985a6d0e5ecec0e5a227c0984708de74661fe Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sun, 14 Jun 2026 21:16:24 +0000 Subject: [PATCH] fix(client): move filter panels out of the scroll container The Library, Series, and Authors filter panels were rendered inside the scrolling
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
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 --- .../features/author/views/AuthorsView.spec.ts | 140 ++++++++++++++++++ .../src/features/author/views/AuthorsView.vue | 6 +- .../features/series/views/SeriesView.spec.ts | 111 ++++++++++++++ .../src/features/series/views/SeriesView.vue | 7 +- client/src/views/HomeView.vue | 122 +++++++-------- 5 files changed, 320 insertions(+), 66 deletions(-) create mode 100644 client/src/features/author/views/AuthorsView.spec.ts create mode 100644 client/src/features/series/views/SeriesView.spec.ts diff --git a/client/src/features/author/views/AuthorsView.spec.ts b/client/src/features/author/views/AuthorsView.spec.ts new file mode 100644 index 000000000..3299d0827 --- /dev/null +++ b/client/src/features/author/views/AuthorsView.spec.ts @@ -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, query: {} as Record, fullPath: '/authors' }, + routerPush: vi.fn<(to: unknown) => Promise>(), + routerReplace: vi.fn<(to: unknown) => Promise>(), + fetchLibraries: vi.fn<() => Promise>(), + load: vi.fn<(reset?: boolean) => Promise>(), + 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([]), + 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
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) + }) +}) diff --git a/client/src/features/author/views/AuthorsView.vue b/client/src/features/author/views/AuthorsView.vue index ad830fe57..de9e431a9 100644 --- a/client/src/features/author/views/AuthorsView.vue +++ b/client/src/features/author/views/AuthorsView.vue @@ -614,9 +614,9 @@ watch( /> -
+ +
+
+
{{ error }}
diff --git a/client/src/features/series/views/SeriesView.spec.ts b/client/src/features/series/views/SeriesView.spec.ts new file mode 100644 index 000000000..24d83170c --- /dev/null +++ b/client/src/features/series/views/SeriesView.spec.ts @@ -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, query: {} as Record, fullPath: '/series' }, + routerPush: vi.fn<(to: unknown) => Promise>(), + routerReplace: vi.fn<(to: unknown) => Promise>(), + fetchLibraries: vi.fn<() => Promise>(), + load: vi.fn<(reset?: boolean) => Promise>(), + 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
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) + }) +}) diff --git a/client/src/features/series/views/SeriesView.vue b/client/src/features/series/views/SeriesView.vue index 5c2f38d2a..55cbfa275 100644 --- a/client/src/features/series/views/SeriesView.vue +++ b/client/src/features/series/views/SeriesView.vue @@ -450,10 +450,9 @@ watch(seriesGridGap, (value) => { /> -
- + +
{ @clear="clearFilters" @close="closeFiltersPanel" /> +
+
+ +
+
+ Filter rules +
+ + + + + Save this filter as a named Smart Scope + + + + + + {{ isFilterSaved ? 'Filter saved' : 'Save filter for this library' }} + + + + + + Remove saved filter + + +
+
+ +
+
@@ -764,67 +825,6 @@ async function handleToggleCollapse() { - -
-
- Filter rules -
- - - - - Save this filter as a named Smart Scope - - - - - - {{ isFilterSaved ? 'Filter saved' : 'Save filter for this library' }} - - - - - - Remove saved filter - - -
-
- -
-