diff --git a/client/src/features/book/components/CollapsedSeriesCard.vue b/client/src/features/book/components/CollapsedSeriesCard.vue
index 966430201..21131c46a 100644
--- a/client/src/features/book/components/CollapsedSeriesCard.vue
+++ b/client/src/features/book/components/CollapsedSeriesCard.vue
@@ -17,6 +17,7 @@ const STACK_COVER_STEP_PCT = 8
const props = defineProps<{
book: BookCard
showLabel?: boolean
+ selectionMode?: boolean
}>()
const route = useRoute()
@@ -115,11 +116,13 @@ function handleClick() {
}
function openSeriesDetails() {
+ if (props.selectionMode) return
if (props.book.seriesId == null) return
void router.push({ name: 'series-detail', params: { seriesId: props.book.seriesId }, query: { from: route.fullPath } })
}
async function openAuthorDetails() {
+ if (props.selectionMode) return
const authorName = authorQuery.value?.trim()
if (!authorName) return
@@ -138,6 +141,7 @@ async function openAuthorDetails() {
}
function handleLabelClick(field: GridCardLabelField) {
+ if (props.selectionMode) return
if (field === 'author') {
void openAuthorDetails()
return
@@ -193,7 +197,12 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
-
+
({
vi.mock('./CollapsedSeriesCard.vue', () => ({
default: {
name: 'CollapsedSeriesCard',
- props: ['book', 'showLabel'],
- template: '{{ book.id }}
',
+ props: ['book', 'showLabel', 'selectionMode'],
+ template:
+ '{{ book.id }}
',
},
}))
@@ -176,6 +177,28 @@ describe('VirtualBookGrid', () => {
expect(updatedBook?.title).toBe('Updated')
})
+ it.each([false, true])('forwards selection mode to collapsed series cards when virtualized=%s', (virtualized) => {
+ const seriesBook = makeBook(4, {
+ collapsedSeries: {
+ bookCount: 3,
+ readCount: 0,
+ coverBookIds: [4],
+ seriesLatestAddedAt: null,
+ },
+ })
+ const wrapper = mount(VirtualBookGrid, {
+ props: {
+ books: [seriesBook],
+ coverSize: 120,
+ gridGap: 12,
+ selectionMode: true,
+ virtualized,
+ },
+ })
+
+ expect(wrapper.get('[data-testid="collapsed-series-card"]').attributes('data-selection-mode')).toBe('true')
+ })
+
it('scales square cover slots independently of media format in static mode', () => {
const books = [
makeBook(1, {
diff --git a/client/src/features/book/components/VirtualBookGrid.vue b/client/src/features/book/components/VirtualBookGrid.vue
index f17fc0be3..4cfaf04c5 100644
--- a/client/src/features/book/components/VirtualBookGrid.vue
+++ b/client/src/features/book/components/VirtualBookGrid.vue
@@ -277,7 +277,7 @@ defineExpose({ scrollToIndex })
:class="{ 'book-grid-cell--new': props.newBookIds.has(book.id) }"
:style="staticItemStyle(book)"
>
-
+
-
+
-
+
emit('select', id, event),
+ onSelect: handleCheckboxClick,
onCopyRow: () => {},
columnMapGetter: () => combinedColumnMap.value,
})
@@ -580,6 +581,7 @@ function handleNavigate(book: BookCard, colId: string, direction: 'next' | 'prev
function handleRowClick(book: BookCard, e: MouseEvent) {
if (props.selectionMode) {
+ if (book.collapsedSeries) return
emit('select', book.id, e)
return
}
@@ -812,10 +814,11 @@ defineExpose({
>
diff --git a/client/src/features/book/components/__tests__/BookListRow.spec.ts b/client/src/features/book/components/__tests__/BookListRow.spec.ts
index b1d7df0c2..7edf67999 100644
--- a/client/src/features/book/components/__tests__/BookListRow.spec.ts
+++ b/client/src/features/book/components/__tests__/BookListRow.spec.ts
@@ -290,7 +290,7 @@ describe('BookListRow collapsed series', () => {
expect(wrapper.emitted('action')).toBeUndefined()
})
- it('selects the collapsed row instead of navigating in selection mode', async () => {
+ it('does not select a representative book or navigate in selection mode', async () => {
const wrapper = mount(BookListRow, {
props: { book: collapsedSeriesBook, selectionMode: true },
global: globalStubs,
@@ -298,8 +298,9 @@ describe('BookListRow collapsed series', () => {
await wrapper.get('[data-testid="collapsed-series-list-row"]').trigger('click')
- expect(wrapper.emitted('select')).toHaveLength(1)
+ expect(wrapper.emitted('select')).toBeUndefined()
expect(routerPushMock).not.toHaveBeenCalled()
+ expect(wrapper.find('[data-testid="book-selection-checkbox"]').exists()).toBe(false)
})
it('renders a fallback cover when collapsed cover ids are unavailable', () => {
diff --git a/client/src/features/book/components/__tests__/CollapsedSeriesCard.spec.ts b/client/src/features/book/components/__tests__/CollapsedSeriesCard.spec.ts
index 932205411..77b51bed5 100644
--- a/client/src/features/book/components/__tests__/CollapsedSeriesCard.spec.ts
+++ b/client/src/features/book/components/__tests__/CollapsedSeriesCard.spec.ts
@@ -216,6 +216,24 @@ describe('CollapsedSeriesCard', () => {
})
})
+ it('does not navigate while selection mode is replacing collapsed rows', async () => {
+ gridCardPrimaryLabel.value = 'series-title'
+ gridCardSecondaryLabel.value = 'author'
+ cardInfoMode.value = 'below-cover'
+ const wrapper = mount(CollapsedSeriesCard, {
+ props: { book: makeBook(), selectionMode: true, showLabel: true },
+ })
+
+ await wrapper.trigger('click')
+ await wrapper.find('[data-testid="series-hover-action"]').trigger('click')
+ await wrapper.find('[data-testid="grid-card-label-primary"]').trigger('click')
+ await wrapper.find('[data-testid="grid-card-label-secondary"]').trigger('click')
+
+ expect(wrapper.attributes('inert')).toBeDefined()
+ expect(mockRouterPush).not.toHaveBeenCalled()
+ expect(mockFetchAuthors).not.toHaveBeenCalled()
+ })
+
it('uses series id even when series name has special characters', async () => {
const wrapper = mount(CollapsedSeriesCard, {
props: { book: makeBook({ seriesId: 43, seriesName: 'The Wheel & Time' }) },
diff --git a/client/src/features/book/components/__tests__/VirtualBookTable.collapsedSeries.spec.ts b/client/src/features/book/components/__tests__/VirtualBookTable.collapsedSeries.spec.ts
new file mode 100644
index 000000000..ce55f43a0
--- /dev/null
+++ b/client/src/features/book/components/__tests__/VirtualBookTable.collapsedSeries.spec.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it, vi } from 'vitest'
+import { computed, ref, type Ref } from 'vue'
+import { mount } from '@vue/test-utils'
+import type { BookCard } from '@bookorbit/types'
+import VirtualBookTable from '../VirtualBookTable.vue'
+
+const ROW_SIZE = 44
+
+vi.mock('@tanstack/vue-virtual', () => ({
+ useVirtualizer: (options: Ref<{ count: number }>) =>
+ computed(() => ({
+ getVirtualItems: () => Array.from({ length: options.value.count }, (_, index) => ({ index, key: index, start: index * ROW_SIZE })),
+ getTotalSize: () => options.value.count * ROW_SIZE,
+ scrollToIndex: () => {},
+ scrollOffset: 0,
+ })),
+}))
+
+vi.mock('vue-router', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useRouter: () => ({ push: vi.fn<(to: unknown) => void>() }),
+}))
+
+vi.mock('@/features/book/composables/useActiveCustomFields', () => ({
+ useActiveCustomFields: () => ({
+ fields: ref([]),
+ loading: ref(false),
+ initialized: ref(true),
+ refresh: vi.fn<() => Promise>(),
+ }),
+}))
+
+function makeBook(id: number, overrides: Partial = {}): BookCard {
+ return {
+ id,
+ status: 'present',
+ coverAspectRatio: '2/3',
+ title: `Book ${id}`,
+ authors: [],
+ seriesName: null,
+ seriesIndex: null,
+ files: [],
+ publishedDate: null,
+ publishedYear: null,
+ language: null,
+ genres: [],
+ tags: [],
+ rating: null,
+ readingProgress: null,
+ readStatus: null,
+ addedAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: null,
+ metadataScore: null,
+ hasCover: false,
+ hasMetadataLocks: false,
+ lockedFields: [],
+ subtitle: null,
+ publisher: null,
+ pageCount: null,
+ isbn13: null,
+ narrators: [],
+ customMetadata: [],
+ ...overrides,
+ }
+}
+
+const collapsedSeriesBook = makeBook(1, {
+ seriesId: 7,
+ seriesName: 'Discworld',
+ collapsedSeries: { bookCount: 3, readCount: 0, coverBookIds: [1], seriesLatestAddedAt: null },
+})
+const plainBook = makeBook(2)
+
+function mountTable() {
+ return mount(VirtualBookTable, {
+ props: {
+ books: [collapsedSeriesBook, plainBook],
+ sort: [{ field: 'title', dir: 'asc' }],
+ viewType: 'library',
+ selectionMode: true,
+ isSelected: () => false,
+ total: 2,
+ },
+ global: {
+ stubs: {
+ BookTableHeader: true,
+ BookTableCellDispatcher: true,
+ BookTableCollapsedSeriesCell: true,
+ BookTableContextMenu: true,
+ BookTableHeaderContextMenu: true,
+ BookCoverDialog: true,
+ KeyboardShortcutOverlay: true,
+ },
+ },
+ })
+}
+
+function bodyRows(wrapper: ReturnType) {
+ return wrapper.findAll('tbody tr')
+}
+
+describe('VirtualBookTable collapsed series rows', () => {
+ it('omits the row checkbox for a collapsed series while offering it for a book', () => {
+ const wrapper = mountTable()
+ const [seriesRow, bookRow] = bodyRows(wrapper)
+
+ expect(seriesRow?.find('input[type="checkbox"]').exists()).toBe(false)
+ expect(bookRow?.find('input[type="checkbox"]').exists()).toBe(true)
+ })
+
+ it('ignores a click on a collapsed series row', async () => {
+ const wrapper = mountTable()
+
+ await bodyRows(wrapper)[0]?.trigger('click')
+
+ expect(wrapper.emitted('select')).toBeUndefined()
+ })
+
+ it('selects the book behind a normal row', async () => {
+ const wrapper = mountTable()
+
+ await bodyRows(wrapper)[1]?.trigger('click')
+
+ expect(wrapper.emitted('select')?.[0]?.[0]).toBe(plainBook.id)
+ })
+
+ it('skips a collapsed series row when selecting with the keyboard', async () => {
+ const wrapper = mountTable()
+ const grid = wrapper.get('[tabindex="0"]')
+
+ await grid.trigger('keydown', { key: 'ArrowDown' })
+ await grid.trigger('keydown', { key: ' ' })
+
+ expect(wrapper.emitted('select')).toBeUndefined()
+
+ await grid.trigger('keydown', { key: 'ArrowDown' })
+ await grid.trigger('keydown', { key: ' ' })
+
+ expect(wrapper.emitted('select')?.[0]?.[0]).toBe(plainBook.id)
+ })
+})
diff --git a/client/src/features/book/composables/__tests__/useBookSelection.spec.ts b/client/src/features/book/composables/__tests__/useBookSelection.spec.ts
new file mode 100644
index 000000000..e7ad01d30
--- /dev/null
+++ b/client/src/features/book/composables/__tests__/useBookSelection.spec.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from 'vitest'
+import { ref } from 'vue'
+import { useBookSelection } from '../useBookSelection'
+
+describe('useBookSelection', () => {
+ it('uses a provided selection-mode ref as its source of truth', () => {
+ const selectionMode = ref(false)
+ const selection = useBookSelection(selectionMode)
+
+ expect(selection.selectionMode).toBe(selectionMode)
+
+ selection.enterSelectionMode()
+ expect(selectionMode.value).toBe(true)
+
+ selection.toggleBook(12)
+ selection.exitSelectionMode()
+
+ expect(selectionMode.value).toBe(false)
+ expect(selection.selectedIds.value).toEqual(new Set())
+ })
+
+ it('creates an independent selection-mode ref when none is provided', () => {
+ const first = useBookSelection()
+ const second = useBookSelection()
+
+ first.enterSelectionMode()
+
+ expect(first.selectionMode.value).toBe(true)
+ expect(second.selectionMode.value).toBe(false)
+ })
+})
diff --git a/client/src/features/book/composables/__tests__/useBookTableShell.spec.ts b/client/src/features/book/composables/__tests__/useBookTableShell.spec.ts
index 4493e7a16..767fe86a0 100644
--- a/client/src/features/book/composables/__tests__/useBookTableShell.spec.ts
+++ b/client/src/features/book/composables/__tests__/useBookTableShell.spec.ts
@@ -14,6 +14,7 @@ const mocks = vi.hoisted(() => ({
confirmDelete: vi.fn<() => void>(),
routerPush: vi.fn<(to: unknown) => void>(),
setBookContext: vi.fn<(ids: number[], total: number) => void>(),
+ useBookViewSelectionCalls: [] as Array<[unknown, { value: boolean } | undefined]>,
useBookBulkActions: vi.fn<(...args: unknown[]) => object>(() => ({
handleBulkRefreshMetadata: vi.fn<() => void>(),
handleBulkReExtractCover: vi.fn<() => void>(),
@@ -41,15 +42,18 @@ vi.mock('../useTableViewControls', () => ({
}))
vi.mock('../useBookViewSelection', () => ({
- useBookViewSelection: () => ({
- selectedIds: mockSelectedIds,
- selectionMode: mockSelectionMode,
- enterSelectionMode: mocks.enterSelectionMode,
- exitSelectionMode: mocks.exitSelectionMode,
- toggleBook: mocks.toggleBook,
- handleSelect: vi.fn<() => void>(),
- toggleSelectionMode: vi.fn<() => void>(),
- }),
+ useBookViewSelection: (books: unknown, selectionMode?: { value: boolean }) => {
+ mocks.useBookViewSelectionCalls.push([books, selectionMode])
+ return {
+ selectedIds: mockSelectedIds,
+ selectionMode: selectionMode ?? mockSelectionMode,
+ enterSelectionMode: mocks.enterSelectionMode,
+ exitSelectionMode: mocks.exitSelectionMode,
+ toggleBook: mocks.toggleBook,
+ handleSelect: vi.fn<() => void>(),
+ toggleSelectionMode: vi.fn<() => void>(),
+ }
+ },
}))
vi.mock('../useDeleteBook', () => ({
@@ -116,6 +120,7 @@ describe('useBookTableShell', () => {
mocks.cancelDelete.mockClear()
mocks.confirmDelete.mockClear()
mocks.useBookBulkActions.mockClear()
+ mocks.useBookViewSelectionCalls.length = 0
mocks.routerPush.mockClear()
mocks.setBookContext.mockClear()
})
@@ -149,6 +154,16 @@ describe('useBookTableShell', () => {
expect(thirdArg).toBe(books)
})
+ it('passes an external selection-mode ref to the view selection state', () => {
+ const books = ref([makeBook()])
+ const selectionMode = ref(false)
+
+ const shell = useBookTableShell({ books, selectionMode })
+
+ expect(mocks.useBookViewSelectionCalls).toEqual([[books, selectionMode]])
+ expect(shell.selectionMode).toBe(selectionMode)
+ })
+
describe('handleBookAction', () => {
it('sets quickViewBookId and opens quick view for quick-view action', () => {
const books = ref([makeBook({ id: 7 })])
diff --git a/client/src/features/book/composables/__tests__/useBookViewWindow.spec.ts b/client/src/features/book/composables/__tests__/useBookViewWindow.spec.ts
index 77855e9a1..50c1eab0c 100644
--- a/client/src/features/book/composables/__tests__/useBookViewWindow.spec.ts
+++ b/client/src/features/book/composables/__tests__/useBookViewWindow.spec.ts
@@ -22,6 +22,7 @@ vi.mock('../useBookEvents', () => ({
import { BOOK_WINDOW_BLOCK_SIZE } from '../useBookWindow'
import { useBookViewWindow } from '../useBookViewWindow'
+import { useEffectiveSeriesCollapse } from '../useEffectiveSeriesCollapse'
function makeBook(id: number): BookCard {
return {
@@ -87,12 +88,24 @@ async function flush() {
await nextTick()
}
-function setup(options: { scope?: number | null; viewMode?: string; collapse?: boolean; q?: string; railEnabled?: boolean; enabled?: boolean } = {}) {
+function setup(
+ options: {
+ scope?: number | null
+ viewMode?: string
+ collapse?: boolean
+ selectionMode?: boolean
+ q?: string
+ railEnabled?: boolean
+ enabled?: boolean
+ } = {},
+) {
const scopeId = ref(options.scope === undefined ? 1 : options.scope)
const viewMode = ref(options.viewMode ?? 'table')
const railEnabled = ref(options.railEnabled ?? true)
const enabled = ref(options.enabled ?? true)
- const collapseEnabled = ref(options.collapse ?? false)
+ const collapsePreference = ref(options.collapse ?? false)
+ const selectionMode = ref(options.selectionMode ?? false)
+ const collapseEnabled = useEffectiveSeriesCollapse(collapsePreference, selectionMode)
const q = ref(options.q ?? '')
const win = useBookViewWindow({
scopeId,
@@ -104,7 +117,7 @@ function setup(options: { scope?: number | null; viewMode?: string; collapse?: b
q,
enabled,
})
- return { win, scopeId, viewMode, railEnabled, enabled, collapseEnabled, q }
+ return { win, scopeId, viewMode, railEnabled, enabled, collapsePreference, selectionMode, collapseEnabled, q }
}
function listRequests(): number[] {
@@ -113,6 +126,12 @@ function listRequests(): number[] {
.map(([, init]) => (JSON.parse(String(init?.body)) as { pagination: { page: number } }).pagination.page)
}
+function listRequestBodies(): Array> {
+ return fetchMock.mock.calls
+ .filter(([url]) => !url.includes('jump-buckets'))
+ .map(([, init]) => JSON.parse(String(init?.body)) as Record)
+}
+
function jumpRequestCount(): number {
return fetchMock.mock.calls.filter(([url]) => url.includes('jump-buckets')).length
}
@@ -240,6 +259,44 @@ describe('useBookViewWindow', () => {
})
})
+ it('reloads uncollapsed books for selection mode and restores collapse afterward', async () => {
+ mockApi(120)
+ const { win, collapsePreference, selectionMode } = setup({ collapse: true })
+ await flush()
+
+ expect(listRequestBodies()).toHaveLength(1)
+ expect(listRequestBodies()[0]).toMatchObject({ collapseSeries: true, pagination: { page: 0 } })
+
+ fetchMock.mockClear()
+ selectionMode.value = true
+ await flush()
+
+ expect(win.query.value).not.toHaveProperty('collapseSeries')
+ expect(listRequestBodies()).toHaveLength(1)
+ expect(listRequestBodies()[0]).not.toHaveProperty('collapseSeries')
+ expect(collapsePreference.value).toBe(true)
+
+ fetchMock.mockClear()
+ selectionMode.value = false
+ await flush()
+
+ expect(listRequestBodies()).toHaveLength(1)
+ expect(listRequestBodies()[0]).toMatchObject({ collapseSeries: true, pagination: { page: 0 } })
+ expect(collapsePreference.value).toBe(true)
+ })
+
+ it('does not reload when selection mode cannot change the effective collapse state', async () => {
+ mockApi(120)
+ const { selectionMode } = setup({ collapse: false })
+ await flush()
+ fetchMock.mockClear()
+
+ selectionMode.value = true
+ await flush()
+
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
it('reverses the letter template for a descending sort', async () => {
mockApi(120)
const { win } = setup()
diff --git a/client/src/features/book/composables/__tests__/useEffectiveSeriesCollapse.spec.ts b/client/src/features/book/composables/__tests__/useEffectiveSeriesCollapse.spec.ts
new file mode 100644
index 000000000..9f96be4f6
--- /dev/null
+++ b/client/src/features/book/composables/__tests__/useEffectiveSeriesCollapse.spec.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest'
+import { ref } from 'vue'
+import { useEffectiveSeriesCollapse } from '../useEffectiveSeriesCollapse'
+
+describe('useEffectiveSeriesCollapse', () => {
+ it('suppresses collapse during selection without changing the saved preference', () => {
+ const preference = ref(true)
+ const selectionMode = ref(false)
+ const effective = useEffectiveSeriesCollapse(preference, selectionMode)
+
+ expect(effective.value).toBe(true)
+
+ selectionMode.value = true
+
+ expect(effective.value).toBe(false)
+ expect(preference.value).toBe(true)
+
+ selectionMode.value = false
+
+ expect(effective.value).toBe(true)
+ expect(preference.value).toBe(true)
+ })
+
+ it('keeps collapse disabled when the saved preference is disabled', () => {
+ const preference = ref(false)
+ const selectionMode = ref(false)
+ const effective = useEffectiveSeriesCollapse(preference, selectionMode)
+
+ selectionMode.value = true
+ selectionMode.value = false
+
+ expect(effective.value).toBe(false)
+ })
+
+ it('uses the latest saved preference after selection mode exits', () => {
+ const preference = ref(true)
+ const selectionMode = ref(true)
+ const effective = useEffectiveSeriesCollapse(preference, selectionMode)
+
+ preference.value = false
+ expect(effective.value).toBe(false)
+
+ selectionMode.value = false
+ expect(effective.value).toBe(false)
+
+ preference.value = true
+ expect(effective.value).toBe(true)
+ })
+})
diff --git a/client/src/features/book/composables/__tests__/useTableKeyboard.spec.ts b/client/src/features/book/composables/__tests__/useTableKeyboard.spec.ts
index 1da48ece6..a754fbbe4 100644
--- a/client/src/features/book/composables/__tests__/useTableKeyboard.spec.ts
+++ b/client/src/features/book/composables/__tests__/useTableKeyboard.spec.ts
@@ -32,7 +32,7 @@ describe('useTableKeyboard', () => {
let displayColumns: Ref
let activeCellKey: Ref
let onActivate: (book: BookCard, colId: string) => void
- let onSelect: (id: number, event: MouseEvent) => void
+ let onSelect: (book: BookCard, event: MouseEvent) => void
let scrollToIndexSpy: ReturnType
function createKeyboard() {
@@ -58,7 +58,7 @@ describe('useTableKeyboard', () => {
displayColumns = ref(makeColumns())
activeCellKey = ref(null)
onActivate = vi.fn<(book: BookCard, colId: string) => void>()
- onSelect = vi.fn<(id: number, event: MouseEvent) => void>()
+ onSelect = vi.fn<(book: BookCard, event: MouseEvent) => void>()
scrollToIndexSpy = vi.fn<() => void>()
})
@@ -180,7 +180,7 @@ describe('useTableKeyboard', () => {
})
keyboard.handleTableKeydown(keyEvent('ArrowDown'))
keyboard.handleTableKeydown(keyEvent(' '))
- expect(onSelect).toHaveBeenCalledWith(1, expect.anything())
+ expect(onSelect).toHaveBeenCalledWith(books[0], expect.anything())
})
it('does nothing when activeCell is set', () => {
diff --git a/client/src/features/book/composables/useBookSelection.ts b/client/src/features/book/composables/useBookSelection.ts
index 0cf9eedf5..12bfba13f 100644
--- a/client/src/features/book/composables/useBookSelection.ts
+++ b/client/src/features/book/composables/useBookSelection.ts
@@ -1,7 +1,6 @@
-import { computed, ref } from 'vue'
+import { computed, ref, type Ref } from 'vue'
-export function useBookSelection() {
- const selectionMode = ref(false)
+export function useBookSelection(selectionMode: Ref = ref(false)) {
const selectedIds = ref>(new Set())
const selectedCount = computed(() => selectedIds.value.size)
const anchorId = ref(null)
diff --git a/client/src/features/book/composables/useBookTableShell.ts b/client/src/features/book/composables/useBookTableShell.ts
index 73199b50c..8e9ef3d79 100644
--- a/client/src/features/book/composables/useBookTableShell.ts
+++ b/client/src/features/book/composables/useBookTableShell.ts
@@ -11,6 +11,7 @@ import { useBookBulkActions, type QuerySelectionState } from './useBookBulkActio
interface BookTableShellOptions {
viewType?: 'library' | 'collection' | 'smartScope'
books: Ref
+ selectionMode?: Ref
total?: Ref
loading?: Ref
exitSelectionMode?: () => void
@@ -21,11 +22,11 @@ interface BookTableShellOptions {
type BookActionType = 'quick-view' | 'add-to-collection' | 'move-to-library' | 'delete'
-export function useBookTableShell({ books, querySelection, onMoveToLibrary }: BookTableShellOptions) {
+export function useBookTableShell({ books, selectionMode, querySelection, onMoveToLibrary }: BookTableShellOptions) {
const router = useRouter()
const { setBookContext } = useBookNavigation()
const tableControls = useTableViewControls()
- const selection = useBookViewSelection(books)
+ const selection = useBookViewSelection(books, selectionMode)
const {
pendingId: deleteBookId,
diff --git a/client/src/features/book/composables/useBookViewSelection.ts b/client/src/features/book/composables/useBookViewSelection.ts
index cdd59a32c..c41a28ee8 100644
--- a/client/src/features/book/composables/useBookViewSelection.ts
+++ b/client/src/features/book/composables/useBookViewSelection.ts
@@ -2,8 +2,8 @@ import { computed, type Ref } from 'vue'
import type { BookCard } from '@bookorbit/types'
import { useBookSelection } from './useBookSelection'
-export function useBookViewSelection(books: Ref) {
- const selection = useBookSelection()
+export function useBookViewSelection(books: Ref, selectionMode?: Ref) {
+ const selection = useBookSelection(selectionMode)
const bookIds = computed(() => books.value.map((book) => book.id))
diff --git a/client/src/features/book/composables/useEffectiveSeriesCollapse.ts b/client/src/features/book/composables/useEffectiveSeriesCollapse.ts
new file mode 100644
index 000000000..bb4c6aade
--- /dev/null
+++ b/client/src/features/book/composables/useEffectiveSeriesCollapse.ts
@@ -0,0 +1,5 @@
+import { computed, type Ref } from 'vue'
+
+export function useEffectiveSeriesCollapse(preference: Ref, selectionMode: Ref) {
+ return computed(() => preference.value && !selectionMode.value)
+}
diff --git a/client/src/features/book/composables/useTableKeyboard.ts b/client/src/features/book/composables/useTableKeyboard.ts
index b5938f010..43a71ced7 100644
--- a/client/src/features/book/composables/useTableKeyboard.ts
+++ b/client/src/features/book/composables/useTableKeyboard.ts
@@ -32,7 +32,7 @@ export function useTableKeyboard(opts: {
virtualizer: Ref<{ scrollToIndex: (index: number, opts?: Record) => void }>
isCellReadOnly: (book: BookCard, col: { id: string; isEditable: boolean }) => boolean
onActivate: (book: BookCard, colId: string) => void
- onSelect: (id: number, event: MouseEvent) => void
+ onSelect: (book: BookCard, event: MouseEvent) => void
onCopyRow?: (book: BookCard) => void
columnMapGetter?: () => Map
}) {
@@ -158,7 +158,7 @@ export function useTableKeyboard(opts: {
event.preventDefault()
if (focusedRowIndex.value >= books.length) return
const book = books[focusedRowIndex.value]
- if (book) opts.onSelect(book.id, event as unknown as MouseEvent)
+ if (book) opts.onSelect(book, event as unknown as MouseEvent)
} else if (isCopyShortcut) {
event.preventDefault()
const target = resolveCopyTarget(event, books, columns)
diff --git a/client/src/locales/en.json b/client/src/locales/en.json
index 163edc16a..bd1df83ad 100644
--- a/client/src/locales/en.json
+++ b/client/src/locales/en.json
@@ -6871,6 +6871,7 @@
"clearAllFilters": "Clear all filters",
"expandSeries": "Expand series",
"collapseSeries": "Collapse series",
+ "collapseLockedWhileSelecting": "Series stay expanded while selecting",
"expanded": "Expanded",
"exportMetadata": "Export metadata",
"showControlsAria": "Show library controls",
diff --git a/client/src/views/CollectionView.vue b/client/src/views/CollectionView.vue
index 2ad6f7e4c..c5e922512 100644
--- a/client/src/views/CollectionView.vue
+++ b/client/src/views/CollectionView.vue
@@ -26,6 +26,7 @@ import { toast } from 'vue-sonner'
import { useCollections } from '@/features/collection/composables/useCollections'
import { useBookViewWindow } from '@/features/book/composables/useBookViewWindow'
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
+import { useEffectiveSeriesCollapse } from '@/features/book/composables/useEffectiveSeriesCollapse'
import { useViewSearch } from '@/features/book/composables/useViewSearch'
import { useEffectiveViewMode } from '@/composables/useEffectiveViewMode'
import { useViewDisplaySettings } from '@/composables/useViewDisplaySettings'
@@ -67,6 +68,8 @@ usePageTitle(pageTitle)
const { getEffectivePreference, setPreference, prefs } = useSeriesCollapsePreference()
const collapseEnabledRef = ref(getEffectivePreference({ collectionId: collectionId.value }))
+const selectionMode = ref(false)
+const effectiveCollapseEnabled = useEffectiveSeriesCollapse(collapseEnabledRef, selectionMode)
watch(collectionId, (id) => {
collapseEnabledRef.value = getEffectivePreference({ collectionId: id })
@@ -113,7 +116,7 @@ const {
viewMode: effectiveViewMode,
railEnabled: showJumpRails,
railViewport: mainRef,
- collapseEnabled: collapseEnabledRef,
+ collapseEnabled: effectiveCollapseEnabled,
q: debouncedQuery,
})
const { sortModel: tableSortModel } = useViewSort(tableSort, 'collection', collectionId)
@@ -170,7 +173,7 @@ function handleTableDensityChange(value: 'compact' | 'comfortable' | 'roomy') {
}
function handleSelectAllLoaded(checked: boolean) {
- const ids = books.value.map((book) => book.id)
+ const ids = books.value.filter((book) => !book.collapsedSeries).map((book) => book.id)
if (checked) selectAll(ids)
else deselectAll(ids)
}
@@ -187,7 +190,6 @@ const {
handleDuplicateTablePreset,
handleTogglePresetFavorite,
handleImportPresetBackup,
- selectionMode,
selectedIds,
selectedCount,
enterSelectionMode,
@@ -220,6 +222,7 @@ const {
handleEditIndividually,
} = useBookTableShell({
books,
+ selectionMode,
onMoveToLibrary: (bookId) => openMoveForBook(bookId),
})
@@ -323,7 +326,14 @@ async function handleBulkEditConfirm(fields: BulkEditFields) {
}
}
+const collapseToggleLabel = computed(() => (effectiveCollapseEnabled.value ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')))
+const collapseToggleHint = computed(() => (selectionMode.value ? t('views.bookView.collapseLockedWhileSelecting') : collapseToggleLabel.value))
+const collapseMenuLabel = computed(() =>
+ selectionMode.value ? t('views.bookView.collapseLockedWhileSelecting') : t('views.bookView.collapseSeries'),
+)
+
async function handleToggleCollapse() {
+ if (selectionMode.value) return
const next = !collapseEnabledRef.value
collapseEnabledRef.value = next
await setPreference({ collectionId: collectionId.value }, next)
@@ -482,19 +492,20 @@ defineOptions({ name: 'CollectionView' })
- {{ collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries') }}
+ {{ collapseToggleHint }}
@@ -534,10 +545,10 @@ defineOptions({ name: 'CollectionView' })
-
-
+
+
- {{ t('views.bookView.collapseSeries') }}
+ {{ collapseMenuLabel }}
diff --git a/client/src/views/HomeView.vue b/client/src/views/HomeView.vue
index e4f4686e2..bb85aaa1a 100644
--- a/client/src/views/HomeView.vue
+++ b/client/src/views/HomeView.vue
@@ -44,6 +44,7 @@ import type { BookCard } from '@bookorbit/types'
import { useBookViewWindow } from '@/features/book/composables/useBookViewWindow'
import { useViewSearch } from '@/features/book/composables/useViewSearch'
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
+import { useEffectiveSeriesCollapse } from '@/features/book/composables/useEffectiveSeriesCollapse'
import { useBookEvents } from '@/features/book/composables/useBookEvents'
import { useBookNavigation } from '@/features/book/composables/useBookNavigation'
@@ -95,6 +96,8 @@ provide(COVER_ASPECT_RATIO_KEY, currentCoverAspectRatio)
const { getEffectivePreference, setPreference, prefs } = useSeriesCollapsePreference()
const collapseEnabledRef = ref(libraryId.value !== null ? getEffectivePreference({ libraryId: libraryId.value }) : false)
+const selectionMode = ref(false)
+const effectiveCollapseEnabled = useEffectiveSeriesCollapse(collapseEnabledRef, selectionMode)
watch(libraryId, (id) => {
collapseEnabledRef.value = id !== null ? getEffectivePreference({ libraryId: id }) : false
@@ -143,7 +146,7 @@ const {
viewMode: effectiveViewMode,
railEnabled: showJumpRails,
railViewport: mainRef,
- collapseEnabled: collapseEnabledRef,
+ collapseEnabled: effectiveCollapseEnabled,
q: debouncedQuery,
})
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
@@ -239,7 +242,7 @@ function handleTableDensityChange(value: 'compact' | 'comfortable' | 'roomy') {
}
function handleSelectAllLoaded(checked: boolean) {
- const ids = books.value.map((book) => book.id)
+ const ids = books.value.filter((book) => !book.collapsedSeries).map((book) => book.id)
if (checked) {
selectAll(ids)
if (total.value > books.value.length) {
@@ -388,7 +391,6 @@ const {
handleDuplicateTablePreset,
handleTogglePresetFavorite,
handleImportPresetBackup,
- selectionMode,
selectedIds,
selectedCount,
enterSelectionMode,
@@ -422,6 +424,7 @@ const {
handleEditIndividually,
} = useBookTableShell({
books,
+ selectionMode,
querySelection,
onMoveToLibrary: (bookId) => openMoveForBook(bookId),
})
@@ -569,7 +572,14 @@ function hasAddOrRemoveFields(fields: BulkEditFields): boolean {
return [fields.authors, fields.genres, fields.tags, fields.narrators].some((f) => f && f.mode !== 'replace')
}
+const collapseToggleLabel = computed(() => (effectiveCollapseEnabled.value ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')))
+const collapseToggleHint = computed(() => (selectionMode.value ? t('views.bookView.collapseLockedWhileSelecting') : collapseToggleLabel.value))
+const collapseMenuLabel = computed(() =>
+ selectionMode.value ? t('views.bookView.collapseLockedWhileSelecting') : t('views.bookView.collapseSeries'),
+)
+
async function handleToggleCollapse() {
+ if (selectionMode.value) return
if (libraryId.value === null) return
const next = !collapseEnabledRef.value
collapseEnabledRef.value = next
@@ -639,19 +649,20 @@ defineOptions({ name: 'HomeView' })
- {{ collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries') }}
+ {{ collapseToggleHint }}
@@ -713,10 +724,10 @@ defineOptions({ name: 'HomeView' })
-
-
+
+
- {{ t('views.bookView.collapseSeries') }}
+ {{ collapseMenuLabel }}
diff --git a/client/src/views/SmartScopeView.vue b/client/src/views/SmartScopeView.vue
index 45a9d2b4b..210857be6 100644
--- a/client/src/views/SmartScopeView.vue
+++ b/client/src/views/SmartScopeView.vue
@@ -42,6 +42,7 @@ import JumpRail from '@/features/book/components/JumpRail.vue'
import { toast } from 'vue-sonner'
import { useBookViewWindow } from '@/features/book/composables/useBookViewWindow'
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
+import { useEffectiveSeriesCollapse } from '@/features/book/composables/useEffectiveSeriesCollapse'
import { useSmartScopes } from '@/features/smart-scope/composables/useSmartScopes'
import { useSmartScopeKoboSync } from '@/features/smart-scope/composables/useSmartScopeKoboSync'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
@@ -78,6 +79,8 @@ const { allSavedViews, saveView, renameView, deleteView, duplicateView, toggleFa
const { getEffectivePreference, setPreference, prefs } = useSeriesCollapsePreference()
const collapseEnabledRef = ref(getEffectivePreference({ smartScopeId: smartScopeId.value }))
+const selectionMode = ref(false)
+const effectiveCollapseEnabled = useEffectiveSeriesCollapse(collapseEnabledRef, selectionMode)
watch(smartScopeId, (id) => {
collapseEnabledRef.value = getEffectivePreference({ smartScopeId: id })
@@ -124,7 +127,7 @@ const {
viewMode: effectiveViewMode,
railEnabled: showJumpRails,
railViewport: mainRef,
- collapseEnabled: collapseEnabledRef,
+ collapseEnabled: effectiveCollapseEnabled,
q: debouncedQuery,
enabled: scopeQueryReady,
})
@@ -199,7 +202,7 @@ function handleTableDensityChange(value: 'compact' | 'comfortable' | 'roomy') {
}
function handleSelectAllLoaded(checked: boolean) {
- const ids = books.value.map((book) => book.id)
+ const ids = books.value.filter((book) => !book.collapsedSeries).map((book) => book.id)
if (checked) selectAll(ids)
else deselectAll(ids)
}
@@ -216,7 +219,6 @@ const {
handleDuplicateTablePreset,
handleTogglePresetFavorite,
handleImportPresetBackup,
- selectionMode,
selectedIds,
selectedCount,
enterSelectionMode,
@@ -249,6 +251,7 @@ const {
handleEditIndividually,
} = useBookTableShell({
books,
+ selectionMode,
onMoveToLibrary: (bookId) => openMoveForBook(bookId),
})
@@ -390,7 +393,14 @@ function openMetadataExport() {
collapseMobileControlsIfNeeded()
}
+const collapseToggleLabel = computed(() => (effectiveCollapseEnabled.value ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')))
+const collapseToggleHint = computed(() => (selectionMode.value ? t('views.bookView.collapseLockedWhileSelecting') : collapseToggleLabel.value))
+const collapseMenuLabel = computed(() =>
+ selectionMode.value ? t('views.bookView.collapseLockedWhileSelecting') : t('views.bookView.collapseSeries'),
+)
+
async function handleToggleCollapse() {
+ if (selectionMode.value) return
const next = !collapseEnabledRef.value
collapseEnabledRef.value = next
await setPreference({ smartScopeId: smartScopeId.value }, next)
@@ -566,20 +576,21 @@ defineOptions({ name: 'SmartScopeView' })
- {{ collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries') }}
+ {{ collapseToggleHint }}
@@ -658,10 +669,10 @@ defineOptions({ name: 'SmartScopeView' })
-
-
+
+
- {{ t('views.bookView.collapseSeries') }}
+ {{ collapseMenuLabel }}