fix(series): make select mode work when series are collapsed

Selecting inside a collapsed library was broken in every layout: a grid series card navigated
into the series instead of selecting, and a list or table series row selected only the
representative book rather than the series it stands for.

Suppress collapse for as long as select mode is active, so the list reloads uncollapsed and
every row is an individual, selectable book. The saved collapse preference is untouched and
comes back on exit. The toolbar toggle stays focusable while locked and explains itself
through its tooltip, since entering select mode visibly re-lays out the view.

Closes #963
This commit is contained in:
neon
2026-08-10 18:36:27 +00:00
parent bf8bb52fc7
commit c113c63bde
22 changed files with 460 additions and 75 deletions
@@ -188,6 +188,7 @@ function collapsedCoverSrc(bookId: number): string {
function handleRowClick(event: MouseEvent) {
if (props.selectionMode) {
if (isCollapsedSeries.value) return
emit('select', event)
return
}
@@ -212,19 +213,11 @@ function handleRowClick(event: MouseEvent) {
data-testid="collapsed-series-list-row"
class="flex items-center gap-3 py-3 px-2 rounded-md transition-colors"
:class="[
selectionMode ? 'cursor-pointer select-none' : canOpenSeries ? 'cursor-pointer hover:bg-muted/50' : 'cursor-default',
selectionMode ? 'cursor-default select-none' : canOpenSeries ? 'cursor-pointer hover:bg-muted/50' : 'cursor-default',
selected ? 'bg-primary/8 ring-1 ring-primary/30' : '',
]"
@click="handleRowClick"
>
<div
v-if="selectionMode"
class="h-5 w-5 rounded shrink-0 flex items-center justify-center transition-colors"
:class="selected ? 'bg-primary' : 'border border-border bg-background'"
>
<Check v-if="selected" class="text-primary-foreground" :size="12" />
</div>
<div :class="collapsedCoverContainerClass">
<template v-if="collapsedCoverIds.length > 0">
<BookCoverSurface
@@ -306,6 +299,7 @@ function handleRowClick(event: MouseEvent) {
<!-- Selection checkbox -->
<div
v-if="selectionMode"
data-testid="book-selection-checkbox"
class="h-5 w-5 rounded shrink-0 flex items-center justify-center transition-colors"
:class="selected ? 'bg-primary' : 'border border-border bg-background'"
>
@@ -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
</script>
<template>
<div class="flex flex-col @container cursor-pointer" @click="handleClick">
<div
class="flex flex-col @container"
:class="selectionMode ? 'cursor-default' : 'cursor-pointer'"
:inert="selectionMode || undefined"
@click="handleClick"
>
<!-- Cover -->
<div class="group">
<BookCoverSurface
@@ -38,8 +38,9 @@ vi.mock('./BookCoverCard.vue', () => ({
vi.mock('./CollapsedSeriesCard.vue', () => ({
default: {
name: 'CollapsedSeriesCard',
props: ['book', 'showLabel'],
template: '<div data-testid="collapsed-series-card">{{ book.id }}<span v-if="showLabel" data-testid="series-card-label-slot" /></div>',
props: ['book', 'showLabel', 'selectionMode'],
template:
'<div data-testid="collapsed-series-card" :data-selection-mode="String(selectionMode)">{{ book.id }}<span v-if="showLabel" data-testid="series-card-label-slot" /></div>',
},
}))
@@ -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, {
@@ -277,7 +277,7 @@ defineExpose({ scrollToIndex })
:class="{ 'book-grid-cell--new': props.newBookIds.has(book.id) }"
:style="staticItemStyle(book)"
>
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" :show-label="showLabel" />
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" :show-label="showLabel" :selection-mode="selectionMode" />
<BookCoverCard
v-else
:book="book"
@@ -295,7 +295,7 @@ defineExpose({ scrollToIndex })
<div v-else-if="!virtualized" class="grid w-full max-w-full items-end" :style="staticGridStyle" data-testid="book-grid-static">
<div v-for="book in staticBooks" :key="book.id" class="min-w-0" :class="{ 'book-grid-cell--new': props.newBookIds.has(book.id) }">
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" :show-label="showLabel" />
<CollapsedSeriesCard v-if="book.collapsedSeries" :book="book" :show-label="showLabel" :selection-mode="selectionMode" />
<BookCoverCard
v-else
:book="book"
@@ -328,7 +328,12 @@ defineExpose({ scrollToIndex })
<template #default="{ item }">
<div class="book-grid-cell" :class="{ 'book-grid-cell--new': props.newBookIds.has(item.id) }">
<BookCoverSkeleton v-if="isBookPlaceholder(item)" />
<CollapsedSeriesCard v-else-if="asBook(item).collapsedSeries" :book="asBook(item)" :show-label="showLabel" />
<CollapsedSeriesCard
v-else-if="asBook(item).collapsedSeries"
:book="asBook(item)"
:show-label="showLabel"
:selection-mode="selectionMode"
/>
<BookCoverCard
v-else
:book="asBook(item)"
@@ -389,11 +389,12 @@ function handleSelectAllToggle(event: Event) {
emit('select-all', checked)
}
function handleCheckboxClick(bookId: number, event: MouseEvent) {
function handleCheckboxClick(book: BookCard, event: MouseEvent) {
if (book.collapsedSeries) return
if (!props.selectionMode) {
emit('enter-selection')
}
emit('select', bookId, event)
emit('select', book.id, event)
}
// Sort strip
@@ -459,7 +460,7 @@ const { focusedRowIndex, focusedColIndex, isFocusedCell, handleTableKeydown } =
virtualizer,
isCellReadOnly,
onActivate: handleActivate,
onSelect: (id, event) => 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({
>
<div class="flex items-center justify-center">
<input
v-if="!rowBook(vItem.index).collapsedSeries"
type="checkbox"
class="accent-primary h-3.5 w-3.5 cursor-pointer"
:checked="isSelected?.(rowBook(vItem.index).id) ?? false"
@click.stop="handleCheckboxClick(rowBook(vItem.index).id, $event)"
@click.stop="handleCheckboxClick(rowBook(vItem.index), $event)"
/>
</div>
</td>
@@ -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', () => {
@@ -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' }) },
@@ -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<typeof import('vue-router')>()),
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<void>>(),
}),
}))
function makeBook(id: number, overrides: Partial<BookCard> = {}): 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<typeof mountTable>) {
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)
})
})
@@ -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)
})
})
@@ -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 })])
@@ -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<number | null>(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<Record<string, unknown>> {
return fetchMock.mock.calls
.filter(([url]) => !url.includes('jump-buckets'))
.map(([, init]) => JSON.parse(String(init?.body)) as Record<string, unknown>)
}
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()
@@ -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)
})
})
@@ -32,7 +32,7 @@ describe('useTableKeyboard', () => {
let displayColumns: Ref<ColumnDef[]>
let activeCellKey: Ref<string | null>
let onActivate: (book: BookCard, colId: string) => void
let onSelect: (id: number, event: MouseEvent) => void
let onSelect: (book: BookCard, event: MouseEvent) => void
let scrollToIndexSpy: ReturnType<typeof vi.fn>
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', () => {
@@ -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<boolean> = ref(false)) {
const selectedIds = ref<Set<number>>(new Set())
const selectedCount = computed(() => selectedIds.value.size)
const anchorId = ref<number | null>(null)
@@ -11,6 +11,7 @@ import { useBookBulkActions, type QuerySelectionState } from './useBookBulkActio
interface BookTableShellOptions {
viewType?: 'library' | 'collection' | 'smartScope'
books: Ref<BookCard[]>
selectionMode?: Ref<boolean>
total?: Ref<number>
loading?: Ref<boolean>
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,
@@ -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<BookCard[]>) {
const selection = useBookSelection()
export function useBookViewSelection(books: Ref<BookCard[]>, selectionMode?: Ref<boolean>) {
const selection = useBookSelection(selectionMode)
const bookIds = computed(() => books.value.map((book) => book.id))
@@ -0,0 +1,5 @@
import { computed, type Ref } from 'vue'
export function useEffectiveSeriesCollapse(preference: Ref<boolean>, selectionMode: Ref<boolean>) {
return computed(() => preference.value && !selectionMode.value)
}
@@ -32,7 +32,7 @@ export function useTableKeyboard(opts: {
virtualizer: Ref<{ scrollToIndex: (index: number, opts?: Record<string, unknown>) => 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<string, ColumnDef>
}) {
@@ -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)
+1
View File
@@ -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",
+21 -10
View File
@@ -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' })
<Tooltip>
<TooltipTrigger as-child>
<button
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
:class="
collapseEnabledRef
effectiveCollapseEnabled
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
:aria-label="collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')"
:aria-label="collapseToggleLabel"
:aria-disabled="selectionMode || undefined"
@click="handleToggleCollapse"
>
<Layers :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>{{ collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries') }}</TooltipContent>
<TooltipContent>{{ collapseToggleHint }}</TooltipContent>
</Tooltip>
<Tooltip>
@@ -534,10 +545,10 @@ defineOptions({ name: 'CollectionView' })
</button>
</template>
<template #mobile-menu>
<DropdownMenuItem @click="handleToggleCollapse">
<CheckSquare v-if="collapseEnabledRef" :size="14" class="mr-2" />
<DropdownMenuItem :disabled="selectionMode" @click="handleToggleCollapse">
<CheckSquare v-if="effectiveCollapseEnabled" :size="14" class="mr-2" />
<Square v-else :size="14" class="mr-2" />
{{ t('views.bookView.collapseSeries') }}
{{ collapseMenuLabel }}
</DropdownMenuItem>
</template>
<template v-if="effectiveViewMode === 'table'" #columns>
+21 -10
View File
@@ -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' })
<Tooltip>
<TooltipTrigger as-child>
<button
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
:class="
collapseEnabledRef
effectiveCollapseEnabled
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
:aria-label="collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')"
:aria-label="collapseToggleLabel"
:aria-disabled="selectionMode || undefined"
@click="handleToggleCollapse"
>
<Layers :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>{{ collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries') }}</TooltipContent>
<TooltipContent>{{ collapseToggleHint }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
@@ -713,10 +724,10 @@ defineOptions({ name: 'HomeView' })
</button>
</template>
<template #mobile-menu>
<DropdownMenuItem @click="handleToggleCollapse">
<CheckSquare v-if="collapseEnabledRef" :size="14" class="mr-2" />
<DropdownMenuItem :disabled="selectionMode" @click="handleToggleCollapse">
<CheckSquare v-if="effectiveCollapseEnabled" :size="14" class="mr-2" />
<Square v-else :size="14" class="mr-2" />
{{ t('views.bookView.collapseSeries') }}
{{ collapseMenuLabel }}
</DropdownMenuItem>
</template>
<template v-if="effectiveViewMode === 'table'" #columns>
+21 -10
View File
@@ -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' })
<Tooltip>
<TooltipTrigger as-child>
<button
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors"
class="hidden sm:flex h-8 w-8 items-center justify-center rounded-md border transition-colors aria-disabled:cursor-not-allowed aria-disabled:opacity-50"
:class="
collapseEnabledRef
effectiveCollapseEnabled
? 'border-primary text-primary bg-primary/10'
: 'border-input text-muted-foreground bg-background hover:text-foreground hover:bg-muted'
"
:aria-label="collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries')"
:aria-label="collapseToggleLabel"
:aria-disabled="selectionMode || undefined"
@click="handleToggleCollapse"
>
<Layers :size="14" />
</button>
</TooltipTrigger>
<TooltipContent>
{{ collapseEnabledRef ? t('views.bookView.expandSeries') : t('views.bookView.collapseSeries') }}
{{ collapseToggleHint }}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -658,10 +669,10 @@ defineOptions({ name: 'SmartScopeView' })
</button>
</template>
<template #mobile-menu>
<DropdownMenuItem @click="handleToggleCollapse">
<CheckSquare v-if="collapseEnabledRef" :size="14" class="mr-2" />
<DropdownMenuItem :disabled="selectionMode" @click="handleToggleCollapse">
<CheckSquare v-if="effectiveCollapseEnabled" :size="14" class="mr-2" />
<Square v-else :size="14" class="mr-2" />
{{ t('views.bookView.collapseSeries') }}
{{ collapseMenuLabel }}
</DropdownMenuItem>
</template>
<template v-if="effectiveViewMode === 'table'" #columns>